7 pontos por GN⁺ 2024-10-28 | 1 comentários | Compartilhar no WhatsApp
  • O grande monorepo JavaScript 1JS da Microsoft cresceu para 1.000 usuários ativos por mês, cerca de 2.500 pacotes e aproximadamente 20 milhões de linhas de código; recentemente, o tamanho do clone chegou a 178GB, a ponto de alguns desenvolvedores nem conseguirem clonar o repositório
  • O primeiro gargalo identificado foi a estrutura em que os arquivos de mudança do Beachball se acumulavam aos dezenas de milhares em uma única pasta; quando esse número chegou a 40.000 arquivos, cada novo arquivo adicionado passava a gerar um grande tree object
  • A causa maior era que, em situações em que nomes longos de arquivos como CHANGELOG.md e CHANGELOG.json eram atualizados repetidamente, o código de empacotamento existente do Git escolhia alvos de delta olhando apenas para os 16 últimos caracteres do nome do arquivo
  • git repack -adf --window=250 já ajudava, mas ao executar git repack -adf --path-walk, um empacotamento baseado em caminho, no fork do Git da Microsoft 2.47.0.vfs.0.2, o repositório local caiu de 178GB para 5GB
  • Se arquivos com nomes longos mudam com frequência em um monorepo grande, vale verificar pack.usePathWalk true e o novo comando git survey; as mudanças relacionadas também devem chegar ao Git upstream

Escala e gargalos do monorepo 1JS

  • O grande monorepo JavaScript interno 1JS da Microsoft é enorme não só em capacidade de armazenamento, mas também em volume de código e contribuições
    • Mais de 1.000 usuários ativos por mês
    • Cerca de 2.500 pacotes
    • Aproximadamente 20 milhões de linhas de código
  • Um clone recente do repositório tinha 178GB, e alguns desenvolvedores na Europa não conseguiam clonar o repositório por causa do tamanho
  • Quando foi clonado pela primeira vez, ele tinha algo entre 1 e 2GB, mas alguns meses depois já havia crescido para cerca de 4GB, e continuou aumentando desde então

Lição #1: não acumular milhares de arquivos em uma só pasta

  • O git-sizer revelou o problema de blobs grandes e dos arquivos de mudança do Beachball
  • Blobs grandes surgem quando binários são commitados por engano, e podem ser evitados com recursos como limite de tamanho de check-in no Azure DevOps
    • Arquivos que já entraram no histórico não são fáceis de remover depois
  • O 1JS usava Beachball change files para automatizar incrementos de intervalo semver dos pacotes
    • O objetivo de uso é semelhante ao do Changesets
    • A meta é automatizar incrementos de versão de pacotes, como faz o semantic-release
  • Como os arquivos de mudança não eram apagados, eles se acumularam até 40.000 arquivos em uma só pasta, e nesse estado a adição de novos arquivos gerava grandes tree objects
  • Houve duas respostas para isso
    • Um PR do Beachball alterou o comportamento para colocar várias mudanças em um único arquivo de mudança, em vez de um arquivo por pacote
    • Foi criado um pipeline para limpar automaticamente, de forma periódica, a pasta desses arquivos de mudança

Lição #2: branch versioned e o problema de empacotamento dos changelogs

  • O fluxo de versionamento do 1JS mantém a branch versioned, que espelha a main
    • Ela guarda as versões reais dos pacotes para evitar conflitos de Git na main
    • Também permite ver com precisão a relação entre versões semver publicadas como pacotes NPM e commits do Git
  • A branch versioned continha principalmente commits que apenas acrescentavam conteúdo aos arquivos CHANGELOG.md e CHANGELOG.json, mas ficou tão grande que clonar passou a ficar cada vez mais difícil
  • Desde 2021, o repositório crescia naturalmente com a adição anual de centenas de desenvolvedores e centenas de milhares de linhas de código, mas como a taxa de crescimento superou a do monorepo do Office da Microsoft, passou-se a suspeitar de uma causa específica
  • Ao investigar, um desenvolvedor que trabalhou em recursos do Git para monorepos grandes, como shallow checkout e sparse index, descobriu que buscar a branch versioned estava trazendo 125GB de dados Git adicionais

Como o código de empacotamento do Git gerava deltas errados

  • A causa era que o código antigo de empacotamento do Git, ao comparar arquivos antes de compactá-los, olhava apenas para os 16 últimos caracteres do nome do arquivo
  • Normalmente, o Git envia apenas o diff dos arquivos modificados, mas por causa desse problema de empacotamento, arquivos CHANGELOG.md de pacotes diferentes acabavam sendo comparados entre si
  • Por exemplo, ao alterar repo/packages/foo/CHANGELOG.json, o Git podia gerar, durante a preparação do push, um diff contra repo/packages/bar/CHANGELOG.json
  • Com isso, em vários casos o arquivo inteiro acabava sendo reenviado repetidamente, e alguns desses arquivos podiam ter dezenas de MB
  • Os detalhes do problema estão organizados em uma mensagem de Stolee na mailing list do Git

Resultados com repack e path-walk

  • Primeiro, o git repack -adf --window=250 foi usado com uma janela maior para melhorar a compressão do arquivo pack, e o tamanho do repositório caiu bastante
  • Depois, o PR #5171 do git-for-windows adicionou um modo de empacotamento que percorre caminhos do Git, em vez da forma padrão baseada em percorrer commits
  • No fork Microsoft git git version 2.47.0.vfs.0.2, foi feito um novo clone e executado o repack com esse método
    • Comando executado: git repack -adf --path-walk
    • Resultado: o tamanho do repositório caiu de 178GB para 5GB
  • A nova opção de configuração também garante que os deltas corretos sejam gerados no momento do push
    • git config --global pack.usePathWalk true
  • Desenvolvedores que usam git version 2.47.0.vfs.0.2 podem executar um repack após o clone local e reduzir a taxa de crescimento com o novo algoritmo de push path-walk

Impacto em GitHub, Azure DevOps e no Git upstream

  • O GitHub executa repack e garbage collection do Git periodicamente, mas o método de empacotamento atual do GitHub ainda não calcula corretamente os deltas de arquivos como CHANGELOG.md e CHANGELOG.json
  • Outros casos também podem ser afetados quando nomes longos de arquivos com os mesmos 16 caracteres finais são alterados com frequência ao longo do tempo
    • Um exemplo citado são arquivos grandes de strings para i18n
  • O Azure DevOps usado pelo 1JS ainda não faz esse tipo de repack, e trabalhos relacionados estão em andamento para também reduzir o tamanho do repositório no servidor
  • As mudanças relacionadas também devem ser incorporadas ao Git upstream

O que verificar em monorepos grandes

  • Em monorepos grandes, se CHANGELOG.md ou arquivos de nome relativamente longo forem atualizados repetidamente, vale acompanhar as mudanças relacionadas a path-walk
  • O novo comando git survey fornece heurísticas para avaliar o estado do repositório
    • Top Files By Disk Size
    • Top Directories By Inflated Size
    • Top Files By Inflated Size
  • Essas heurísticas ajudam a estimar se o trabalho com path-walk também pode impactar o tamanho desse repositório

1 comentários

 
GN⁺ 2024-10-28
Opiniões do Hacker News
  • Onde está o novo comando git-survey? Ele ainda não entrou no git.git
    O autor está usando o fork do Git da Microsoft, e esse comando foi adicionado neste verão: https://github.com/microsoft/git/pull/667

    • full-name-hash e path-walk também parecem existir apenas no fork ou só no Git HEAD
      Não consegui encontrá-los nas páginas de manual nem no changelog da 2.47
  • Fiquei curioso sobre o que significa dizer que “pessoas na Europa nem conseguem clonar por causa do tamanho do repositório”
    Parece estranho que um repositório grande não possa ser baixado na Europa, mas possa em outros lugares, e soa como se elas ainda não fossem conseguir clonar até que a mudança no lado do servidor fosse aplicada
    A frase “continuavam reenviando o arquivo inteiro por push” parece ter sido cortada no meio, e os GIFs distraem muito durante a leitura, continuando visíveis até no modo de leitura

    • Li essa frase como uma anedota
      Escrita de forma mais completa, seria algo como: “havia uma pessoa que queria clonar o repositório inteiro no notebook para usar enquanto viajava pela Europa, mas na época não conseguiu porque o disco estava cheio. Depois, liberou espaço em disco e conseguiu clonar”
      Não acho que isso signifique um grande problema de a Europa não conseguir lidar com arquivos de 180 GB
    • Aqui, “Europa” provavelmente é uma forma indireta de dizer alta latência e baixa largura de banda
      Especialmente se a pessoa estiver usando VPN, e mais ainda algum truque terrível de “SSL VPN”; softwares que lidam mal com latência ou servidores com window scaling quebrado ainda são surpreendentemente comuns
      Quando a maioria dos desenvolvedores está perto do servidor e usa conexões com boa largura de banda, esse tipo de problema não aparece muito
    • Trabalho na Austrália para outra equipe interna da Microsoft e uso um grande monorepo do Azure DevOps, e quase não consigo fazer um clone completo
      A clonagem pelo Azure DevOps nem chega perto de saturar uma conexão gigabit cabeada e demora tanto que, no fim, algo do meu lado ou do lado do Azure DevOps acaba parando e eu desisto
      Felizmente, faço quase todo o trabalho em clones rasos dentro do Codespaces, então isso não é um grande problema; se no nosso repositório também houver um aumento explosivo de tamanho parecido com o problema do repositório 1JS deste artigo, espero que seja corrigido
    • Talvez o repositório esteja na costa oeste dos EUA, então cada clone feito da Europa precise atravessar o Atlântico
    • Também pode ser em um país com infraestrutura de internet menos desenvolvida, por exemplo a Alemanha
  • Atualização: foi um erro bobo, e o nome do arquivo não inclui o caminho completo
    A explicação parece ter ficado enterrada entre os GIFs, mas aqui os últimos 16 caracteres são diferentes: o exemplo é que você alterou repo/packages/foo/CHANGELOG.md, mas, enquanto o Git preparava o push, ele comparou com repo/packages/bar/CHANGELOG.md e gerou um diff

    • Derrick explica melhor nesta cover letter: https://lore.kernel.org/git/pull.1785.git.1725890210.gitgitg...
      A cover letter da API path-walk também vale a leitura: https://lore.kernel.org/all/pull.1786.git.1725935335.gitgitg...
      O exemplo do blog não é muito claro, mas o Git, na prática, estava pegando todas as versões de todos os arquivos dentro do repositório, colocando os últimos 16 bytes do caminho em uma tabela hash e agrupando para compressão delta aquilo que esperava serem versões diferentes do mesmo arquivo
      foo/CHANGELOG.md e bar/CHANGELOG.md no texto têm só 13 caracteres, então o exemplo, ao pé da letra, não bate; é preciso imaginar caminhos com um sufixo comum mais longo
      Essa parte foi corrigida com a opção --full-name-hash, que agora compara o caminho inteiro, não apenas 16 bytes
      O aumento do tamanho da janela mencionado em seguida é mais um hack para contornar o agrupamento ruim de arquivos, não uma correção de verdade
      Como ele ainda fornece uma entrada horrível ao compressor e contorna o problema usando uma quantidade enorme de memória, foi confuso ver isso apresentado como solução
      A parte realmente interessante é a API path-walk e --full-name-hash
    • Eu gostaria que o texto tivesse explicado exatamente o que aconteceu, em vez da narrativa “colorida”
      Fiquei curioso se “nome do arquivo” aqui quer dizer caminho, ou se o Git escolhe qualquer arquivo com o mesmo nome e gera um diff
      Também queria saber se há algum padrão na forma como ele escolhe o outro arquivo
    • Sim, o caminho completo é levado em conta
      Basta ver a mensagem do primeiro commit do PR de --full-name-hash: https://github.com/git-for-windows/git/pull/5157/commits/d5c...
      Em resumo, "/CHANGELOG.json" tem 15 caracteres e é criado pela ferramenta beachball; só a última letra do diretório pai consegue distinguir as diferentes versões desse arquivo, e mesmo assim só os 2 bits superiores dela são refletidos
      Se essa letra for um caractere, sempre haverá colisão; o caminho "/CHANGELOG.md" tem um problema parecido, embora haja um pouco mais de margem para diferença no diretório pai
      O algoritmo de agrupamento dá peso menor a cada caractere quanto mais distante ele estiver do lado direito do nome: hash = (hash >> 2) + (c << 24)
      O hash tem 32 bits e soma cada caractere de 8 bits do caminho completo, em ordem, aos 8 bits mais significativos do hash; como os bits já existentes do hash são deslocados 2 bits para a direita, só os últimos 16 caracteres afetam o hash final
      Dá para ver o que acontece na prática aqui: https://go.dev/play/p/JQpdUGXdQs7
      Eu portei para Go e comparei os valores finais de "aaa/CHANGELOG.md" e "zzz/CHANGELOG.md"; se você testar vários valores para aaa e zzz, verá como eles influenciam pouco o valor final
    • O nome do arquivo não necessariamente inclui o caminho completo
      Os últimos 16 caracteres de CHANGELOG.md são o nome completo do arquivo e, se interpretarmos assim, isso também explica por que a solução filepathwalk resolve o problema
      Mas, se ele realmente se baseasse só nos últimos 16 caracteres do nome do arquivo e não no caminho completo, esse problema deveria ter sido muito mais comum, especialmente em monorepos
    • Isso também me incomodou
      Acho que os metavariáveis foo e bar podem ter confundido, e é bem possível que os nomes reais dos pacotes tivessem um sufixo comum, como firstPkg e secondPkg
      "/CHANGELOG.md" já tem 13 caracteres, então bastam 3 caracteres de sufixo comum para ocorrer uma colisão de 16 caracteres
  • Testei rapidamente no nixpkgs, que fica com cerca de 5 GB quando clonado diretamente do GitHub
    A primeira opção mencionada no texto, --window 250, reduziu o tamanho para 1,7 GB, e a nova opção --path-walk do fork do Git da Microsoft foi menos eficaz, deixando o total em 1,9 GB
    Ambos ficam com menos da metade do tamanho inicial, então seria ótimo se o GitHub tivesse uma forma de executar esse tipo de operação; melhor ainda seria se as pessoas começassem a hospedar repositórios de um jeito que permitisse controlar isso diretamente

  • O texto menciona Derrick Stolee, que investigou as mudanças necessárias e as lançou
    Se você tem interesse nas estruturas internas do Git, em reduzir o tamanho de clones do Git localmente e em CI, Derrick escreveu muitos posts excelentes no blog do GitHub: https://github.blog/author/dstolee/
    O site pessoal dele também vale conferir: https://stolee.dev/

  • Se você comitou por engano um binário grande, uma forma de corrigir seria rodar filter-branch para remover o binário problemático, ensinar e configurar todo mundo para usar git-lfs para binários, fazer um push forçado e devolver o ambiente de trabalho de cada um a um estado normal
    Não é o ideal, mas é melhor do que continuar mantendo no Git um arquivo grande que nem é usado

    • Para quem tem medo do filter-branch, também existe o BFG: https://rtyley.github.io/bfg-repo-cleaner/
      Como outra pessoa apontou, este problema é sobre arquivos pequenos que mudam com frequência; então, para economizar espaço, basta remover as versões antigas do histórico e usar LFS daqui para frente
    • O problema central não são arquivos binários que entram uma vez e não mudam, mas arquivos binários pequenos que mudam com frequência
    • A abordagem recomendada hoje em dia é o filter-repo: https://github.com/newren/git-filter-repo
    • É mais fácil colocar a culpa no Linus
  • Hackear o Git pode ser divertido, mas fico pensando se não haveria uma forma de, para começo de conversa, não colocar 2.500 pacotes em um monorepo

    • A quantidade de linhas de código tende, em geral, a crescer de forma exponencial
      Quanto maior a base de código fica, mais difícil é esperar que as pessoas não reinventem a roda, seja porque não sabem que aquele código existe, seja porque têm medo de alterá-lo para atender ao seu caso de uso e acabar quebrando usos existentes
      Na prática, parece que leva menos tempo para ir de 100 módulos para 200 do que de 50 para 100
    • Então é só ter 2.500 repositórios Git separados, com todo o overhead associado
    • Também foi exatamente isso que pensei primeiro
      Parece um problema totalmente criado por eles mesmos
  • Tenho acompanhado os avanços relacionados a monorepos e escalabilidade depois de ver as apresentações recentes do GitMerge publicadas pelo GitButler, e há muita coisa boa saindo da Microsoft, do GitHub e do GitLab
    Gostaria de entender melhor como o problema da verificação dos últimos 16 caracteres e da verificação do caminho completo se encaixa com compressão delta, índices de pack e índices multi-pack

    • Se isso é algo tão bom para o open source, fico curioso se eles pretendem abrir um merge request para levar esses comandos Git customizados de volta ao Git oficial
  • É bom ver que a Microsoft está usando diretamente o Azure DevOps
    Como cada vez mais serviços do Azure parecem oferecer conectores nativos apenas para o GitHub, eu achava que o Azure DevOps estava caminhando para ser abandonado

  • Ter alguém que conhece tão bem a estrutura interna do Git ao alcance para ajudar deve ser uma vantagem realmente ótima ao tocar projetos assim numa empresa desse porte

    • Ter uma relação próxima com uma organização como o GitHub certamente ajuda, mas qualquer organização com essa quantidade de desenvolvedores consegue justificar o custo de trazer consultores altamente especializados para resolver um problema quase de nicho como esse