1 pontos por GN⁺ 2025-03-19 | 1 comentários | Compartilhar no WhatsApp
  • Ao recompilar o mesmo código-fonte do jq e 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 SitusCity com a condição TotalNetValue < 193000
  • Só a recompilação simples já trouxe melhora de 2–4%, e a combinação de clang-18, -O3, -flto e -DNDEBUG entregou 1,20x o desempenho do pacote Ubuntu
  • O perfil mostrou alto custo de alocação de memória, então foram comparados TCMalloc, jemalloc e mimalloc; nos testes com LD_PRELOAD, o mimalloc foi o mais rápido
  • A build final com linkagem de mimalloc també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 SitusCity dos itens da lista de parcels que atendem à condição TotalNetValue < 193000
    • .features[] | select(.properties.TotalNetValue < 193000) | .properties.SitusCity
  • O /usr/bin/jq padrão do Ubuntu levou cerca de 5 segundos com o arquivo já em cache, e o benchmark detalhado foi repetido com hyperfine
  • 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 jq usado 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, -flto e -DNDEBUG
    • -O3 usa um nível de otimização mais agressivo que -O2
    • -flto ativa otimização em tempo de linkedição
    • -DNDEBUG reduz o custo de assertions, que aparecia com destaque no perfil
  • Exemplo de configure usado:
    • CC=clang-18
    • LDFLAGS="-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
  • 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_PRELOAD o jemalloc, o mimalloc e o TCMalloc fornecidos pelo Ubuntu
  • Os resultados abaixo foram obtidos com estas variáveis de ambiente definidas:
    • MIMALLOC_LARGE_OS_PAGES=1
    • MALLOC_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 + mimalloc ficou 31% mais rápido que THP + glibc e 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 jq foi recompilado com linkagem de mimalloc
  • A build final foi 1,90x mais rápida que o pacote binário do Ubuntu
    • jq recompilado com mimalloc: 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
    • jq recompilado com mimalloc: 0,755 s
    • jq do pacote Ubuntu: 1,424 s
  • Também nesse caso separado, o ganho de velocidade ficou perto de 2x

1 comentários

 
GN⁺ 2025-03-19
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_PRELOAD para trocar a implementação de malloc, 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 do malloc padrão, e não um problema da própria aplicação

    • Investiguei o alocador de memória da glibc, e isso não era fragmentação de memória, mas sim caches por thread que nunca são devolvidos ao kernel. Mesmo ao chamar free(), externamente a memória não é realmente liberada, salvo em circunstâncias excepcionais
      Quanto 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=2 para limitar a quantidade de caches. Outra forma é a aplicação chamar malloc_trim() periodicamente para esvaziar os caches, mas isso exige alteração no código-fonte
      https://www.joyfulbikeshedding.com/blog/2019-03-14-what-caus...
    • Sim, eu também quase acreditei por um instante. Mas também é fácil colocar a culpa no Ubuntu como causa do erro. Pessoalmente, acho que o Ubuntu faz um bom trabalho montando os pacotes e, de fato, compila com opções de proteção de pilha habilitadas
      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 -O3
    • Parece claramente exagerado, porque não há como obter esse tipo de melhoria global apenas com a explicação de um único artigo. 90% mais rápido é um número de microbenchmark
    • Fico me perguntando quantas distribuições binárias pré-empacotadas são compiladas com as opções mais seguras para o sistema operacional e o hardware, sem extrair o melhor desempenho possível. Sinceramente, acho que a maioria
      Há 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
    • O título é clickbait, mas é bom incentivar desenvolvedores de apps a tentar recompilar. Especialmente quando a CPU é o gargalo em alguns utilitários comuns como jq, grep, ffmpeg e ocrmypdf, 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ífica
  • Engenharia é 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 prazo
    Experimentei 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

    • Mimalloc é um alocador de uso geral, como JEMalloc / TCMalloc. A glibc é conhecida por ter um alocador bem ruim, e o MIMalloc ou o TCMalloc mais recente — ou seja, não a versão fornecida por padrão no Ubuntu — estão muito à frente da glibc
      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
    • Eu também lido com frames de vídeo 8K grandes [1]. Se estamos falando dos frames em si, 60 alocações por segundo não é nada. Há um único motivo para a glibc ser lenta: cada alocação passa de DEFAULT_MMAP_THRESHOLD_MAX e, 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 de mallopt
      A cada vez, ela pede memória diretamente ao kernel com mmap e a devolve com munmap. 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 de mmap, 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
    • Acho este comentário um pouco difícil de entender. Não conheço muito C nem compiladores C, mas li o gist inteiro, aprendi bastante e achei que tinha valor
      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
    • É por isso que usar um único alocador para tudo no mundo é uma má ideia. É terrível que aplicações single-thread e até aplicações multithread com gerenciamento rigoroso de recursos tenham que pagar o custo de segurança de threads
    • É um sofrimento comum. Quando você usa a linguagem adequada para o projeto, as pessoas dizem que você deveria ter usado outra linguagem por este ou aquele motivo
      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

    • Sim, mas vale destacar que otimização aqui não significa necessariamente desempenho
      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á
    • É a primeira vez que vejo o meme “install gentoo” no estilo HN. Definitivamente mais refinado
      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
    • Usei Gentoo continuamente desde 2003, até que migrei bem recentemente, no fim de 2024, quando experimentei o Void Linux. No Void, permitir que o usuário final compile a partir do código-fonte não é um objetivo declarado nem um recurso arquitetural, mas há uma boa chance de você conseguir fazer isso funcionar na prática
      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ê
    • Usei Gentoo por um tempo, mas a tentação de ficar ajustando tudo sem parar acabava me levando a quebrar o sistema. Não era culpa do Gentoo, era minha
      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
    • Pelo que sei, o ChromeOS baseado em Gentoo está sendo substituído por Android
  • 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. O jq é frequentemente usado para parsear JSON não confiável
    A 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

    • Usar um gerenciador de pacotes em espaço de usuário, como o Gentoo Prefix, permite instalar esse build customizado e ainda continuar recebendo atualizações de segurança
    • Ainda assim, não há aí uma ideia embrionária de um sistema de gerenciamento de pacotes que decide de forma inteligente se deve compilar dependendo da plataforma? O ganho de desempenho parece grande demais para deixar passar
    • Em geral, isso é verdade, mas neste caso está errado. O build descrito no gist ainda faz linkagem dinâmica com a onigurama. A onigurama fica em outro pacote, chamado libonig5, e será atualizada normalmente
    • Fico me perguntando o quanto esses CVEs são geralmente aplicáveis. Parece dizer que, se você usa uma porta comum dentro de casa, está abrindo mão da segurança oferecida por uma porta de cofre. Não é falso, mas há uma razão para nem todas as portas de um banco serem portas de cofre
      Nã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
    • Sem dúvida. Além disso, se você trocar o alocador e mudar as flags do compilador, pode acabar ficando acidentalmente imune a ataques que dependem de um layout de memória específico
  • 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 malloc que não fosse o da libc, mas imagino que o mesmo princípio se aplique

    • Duas coisas opostas são verdadeiras ao mesmo tempo. Se uma pessoa não quer se diferenciar em nada, ela está seguindo o caminho da maioria, e no curto prazo isso tem a maior chance de sucesso
      Mas, 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
    • Há muito tempo compilo meu próprio emacs, e ainda não encontrei bugs estranhos. Eu achava que estava tudo bem desde que evitasse otimizações inseguras
      Claro, eu também achava que -march=native era 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 ásperas
    • Por outro lado, se uma otimização ajuda de forma consistente em várias plataformas, você pode convencer o desenvolvedor upstream a implementá-la diretamente. Não precisa ser em todas as plataformas; se o ganho de desempenho em uma única arquitetura for grande o suficiente, isso pode ser motivo para ajustar a configuração desse build
  • Está mais para recompilar com outro alocador que gera bons benchmarks em um fluxo de trabalho específico

    • Praticamente qualquer coisa consegue ser melhor que o malloc da glibc. O fato de as distribuições continuarem usando o malloc da glibc em vez de mimalloc ou jemalloc é praticamente negligência profissional
    • Sabe-se ao menos em que tipo de carga de trabalho o malloc da glibc é bom?
  • Tenho curiosidade de saber como o desempenho se compara ao deste clone de jq baseado em Rust
    cargo install --locked jaq
    Para ativar otimizações voltadas a uma família específica de CPU, também dá para adicionar RUSTFLAGS="-C target-cpu=native". O cargo 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 junto
    jaq[1] e yq[2] são minhas escolhas sempre que estou usando jq e preciso de um ganho de desempenho rápido e fácil
    [1] https://github.com/01mf02/jaq
    [2] https://github.com/mikefarah/yq

    • De vez em quando comparo jaq com jq e gojq, usando minha solução em jq para o AoC 2022 day 13 como teste
      https://gist.github.com/oguz-ismail/8d0957dfeecc4f816ffee79d...
      Ele ainda fica atrás dos dois
    • Como bônus que talvez as pessoas não conheçam, quando você quer usar diretamente um repositório, o cargo install també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çado
      Já 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 jq
    Depois é 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

    • O mais enganoso ainda é a nuance de que seria possível tornar todos os pacotes 90% mais rápidos. Trata-se de um pacote específico
    • Aqui parece fazer mais sentido usar “90% faster” como uma unidade de throughput, não como uma unidade de tempo
      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
    • Post relacionado: https://randomascii.wordpress.com/2018/02/04/what-we-talk-ab...
    • Pensando melhor, acho que entendi por que é enganoso. É porque se descreve a mudança no valor maior como porcentagem do valor menor
      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
    • Parece certo. Dá para dizer que o pacote, isto é, o código, está 45% mais rápido, ou que o throughput de parsing aumentou 90%. Mas misturar as duas coisas causa confusão
  • 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?