Como reduzimos em 94% o tamanho do git de um monorepo JavaScript
(jonathancreamer.com)- 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.mdeCHANGELOG.jsoneram 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=250já ajudava, mas ao executargit repack -adf --path-walk, um empacotamento baseado em caminho, no fork do Git da Microsoft2.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 truee o novo comandogit 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-sizerrevelou 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 amain- 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
- Ela guarda as versões reais dos pacotes para evitar conflitos de Git na
- A branch
versionedcontinha principalmente commits que apenas acrescentavam conteúdo aos arquivosCHANGELOG.mdeCHANGELOG.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 checkoutesparse index, descobriu que buscar a branchversionedestava 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.mdde 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 contrarepo/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=250foi 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
- Comando executado:
- 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.2podem 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.mdeCHANGELOG.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.mdou arquivos de nome relativamente longo forem atualizados repetidamente, vale acompanhar as mudanças relacionadas a path-walk - O novo comando
git surveyfornece 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
Opiniões do Hacker News
Onde está o novo comando
git-survey? Ele ainda não entrou no git.gitO 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-hashepath-walktambém parecem existir apenas no fork ou só no Git HEADNã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
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
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
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
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 comrepo/packages/bar/CHANGELOG.mde gerou um diffA cover letter da API
path-walktambé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.mdebar/CHANGELOG.mdno 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 longoEssa parte foi corrigida com a opção
--full-name-hash, que agora compara o caminho inteiro, não apenas 16 bytesO 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-hashFiquei 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
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 refletidosSe 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 paiO 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 paraaaaezzz, verá como eles influenciam pouco o valor finalOs últimos 16 caracteres de
CHANGELOG.mdsão o nome completo do arquivo e, se interpretarmos assim, isso também explica por que a soluçãofilepathwalkresolve o problemaMas, 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
Acho que os metavariáveis
fooebarpodem ter confundido, e é bem possível que os nomes reais dos pacotes tivessem um sufixo comum, comofirstPkgesecondPkgSó
"/CHANGELOG.md"já tem 13 caracteres, então bastam 3 caracteres de sufixo comum para ocorrer uma colisão de 16 caracteresTestei rapidamente no
nixpkgs, que fica com cerca de 5 GB quando clonado diretamente do GitHubA primeira opção mencionada no texto,
--window 250, reduziu o tamanho para 1,7 GB, e a nova opção--path-walkdo fork do Git da Microsoft foi menos eficaz, deixando o total em 1,9 GBAmbos 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-branchpara 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 normalNão é o ideal, mas é melhor do que continuar mantendo no Git um arquivo grande que nem é usado
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
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
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
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
É 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