Cómo redujimos en un 94% el tamaño de Git de un monorepo de JavaScript
(jonathancreamer.com)- 1JS, el gran monorepo de JavaScript de Microsoft, creció hasta tener 1,000 usuarios activos mensuales, unos 2,500 paquetes y cerca de 20 millones de líneas, y recientemente su tamaño de clonación llegó a 178 GB, por lo que algunos desarrolladores tenían dificultades incluso para clonarlo
- El primer cuello de botella detectado fue una estructura en la que los archivos de cambios de Beachball se acumulaban por decenas de miles en una sola carpeta; cuando llegaron a 40,000 archivos, cada vez que se agregaba un archivo nuevo se generaba un tree object grande
- La causa mayor era que, en una situación donde archivos con nombres largos como
CHANGELOG.mdyCHANGELOG.jsonse actualizaban repetidamente, el código de empaquetado existente de Git elegía el objetivo del delta mirando solo los últimos 16 caracteres del nombre de archivo git repack -adf --window=250también tuvo efecto, pero al ejecutargit repack -adf --path-walk, un empaquetado basado en rutas, en el fork de Git de Microsoft2.47.0.vfs.0.2, el repositorio local se redujo de 178 GB a 5 GB- Si en un monorepo grande cambian con frecuencia archivos de nombre largo, conviene revisar
pack.usePathWalk truey el nuevo comandogit survey; los cambios relacionados también se incorporarán al upstream de Git
Escala y cuellos de botella del monorepo 1JS
- 1JS, el gran monorepo interno de JavaScript de Microsoft, no solo es grande por el tamaño del repositorio, sino también por la escala de su código y sus contribuciones
- Más de 1,000 usuarios activos mensuales
- Aproximadamente 2,500 paquetes
- Cerca de 20 millones de líneas de código
- El tamaño de un repositorio clonado recientemente era de 178 GB, y algunos desarrolladores en Europa no podían clonarlo debido a su tamaño
- Al principio, cuando se clonaba, estaba en el rango de 1 a 2 GB, pero unos meses después creció a unos 4 GB y siguió aumentando
Lección #1: no acumular miles de archivos en una sola carpeta
git-sizerreveló el problema de blobs grandes y de archivos de cambios de Beachball- Los blobs grandes aparecen cuando se commitean binarios por error, y pueden prevenirse con funciones como los límites de tamaño de check-in de Azure DevOps
- Una vez que los archivos ya entraron al historial, no es fácil eliminarlos después
- 1JS usaba archivos de cambios de Beachball para automatizar los incrementos de rango semver de los paquetes
- Su propósito de uso es similar al de Changesets
- El objetivo es automatizar el incremento de versiones de paquetes, como con semantic-release
- Como no se eliminaban los archivos de cambios, llegaron a acumularse hasta 40,000 en una sola carpeta, y al agregar un archivo nuevo en ese estado se generaba un tree object grande
- La respuesta tuvo dos partes
- Se modificó mediante un PR de Beachball para que, en lugar de un archivo por paquete, varios cambios quedaran dentro de un único archivo de cambios
- Se creó un pipeline para limpiar automáticamente la carpeta de archivos de cambios de forma periódica
Lección #2: ramas versioned y el problema de empaquetado de changelogs
- El flujo de versionado de 1JS mantiene una rama
versionedque es un espejo demain- Guarda las versiones reales de los paquetes para evitar conflictos de Git en
main - Permite ver con precisión la correspondencia entre las versiones semver publicadas como paquetes de NPM y los commits de Git
- Guarda las versiones reales de los paquetes para evitar conflictos de Git en
- La rama
versionedcontenía principalmente commits que agregaban contenido a archivosCHANGELOG.mdyCHANGELOG.json, pero creció tanto que clonar el repositorio se volvió cada vez más difícil - El repositorio venía creciendo de forma natural desde 2021, con cientos de miles de líneas de código y cientos de desarrolladores agregados cada año, pero cuando su tasa de crecimiento superó la del monorepo de Office de Microsoft, se empezó a sospechar de una causa aparte
- Al revisarlo un desarrollador que creó funciones de Git para monorepos grandes, como
shallow checkoutysparse index, encontró que al traer la ramaversionedse estaban haciendo fetch de 125 GB de datos adicionales de Git
Cómo el código de empaquetado de Git generaba deltas incorrectos
- La causa era el comportamiento del código antiguo de empaquetado de Git, que al comparar archivos antes de comprimirlos solo revisaba los últimos 16 caracteres del nombre de archivo
- Normalmente, Git solo hace push del diff de los archivos modificados, pero por este problema de empaquetado se comparaban entre sí archivos
CHANGELOG.mdde paquetes distintos - Por ejemplo, si se modificaba
repo/packages/foo/CHANGELOG.json, durante la preparación del push Git podía generar un diff contrarepo/packages/bar/CHANGELOG.json - Por eso, en muchos casos se terminaba volviendo a hacer push del archivo completo una y otra vez, y algunos archivos podían pesar decenas de MB
- Los detalles del problema están resumidos en el mensaje de Stolee en la lista de correo de Git
Resultados de repack y path-walk
- Primero se usó
git repack -adf --window=250para mejorar la compresión de los pack files con una ventana más grande, y el tamaño del repositorio se redujo de forma considerable - Luego, git-for-windows PR #5171 agregó un método de empaquetado que recorre rutas de Git, en lugar del método predeterminado que recorre commits
- En Microsoft git, el fork de Git de Microsoft,
git version 2.47.0.vfs.0.2, se clonó usando el nuevo método y luego se ejecutó repack- Comando ejecutado:
git repack -adf --path-walk - Resultado: el tamaño del repositorio se redujo de 178 GB a 5 GB
- Comando ejecutado:
- La nueva opción de configuración también permite que se generen deltas correctos al momento de hacer push
git config --global pack.usePathWalk true
- Los desarrolladores que usen
git version 2.47.0.vfs.0.2pueden ejecutar repack después de clonar localmente y reducir la tasa de crecimiento con el nuevo algoritmo de push path-walk
Impacto en GitHub, Azure DevOps y el upstream de Git
- GitHub ejecuta repack y garbage collection de Git de forma periódica, pero el método de empaquetado actual de GitHub no calcula correctamente los deltas de archivos como
CHANGELOG.mdyCHANGELOG.json - También pueden verse afectados los casos en los que nombres de archivo largos que comparten más de los últimos 16 caracteres cambian con frecuencia a lo largo del tiempo
- Como ejemplo, se mencionan archivos grandes de strings para i18n
- Azure DevOps, que usa 1JS, todavía no realiza este tipo de repack, y se está trabajando en cambios relacionados para reducir también el tamaño de los repositorios del lado del servidor
- Los cambios relacionados también se incorporarán al upstream de Git
Qué revisar en monorepos grandes
- Si en un monorepo de gran escala se actualizan repetidamente
CHANGELOG.mdo archivos con nombres relativamente largos, conviene seguir de cerca los cambios relacionados con path-walk - El nuevo comando
git surveyofrece heurísticas para evaluar el estado del repositorio- Top Files By Disk Size
- Top Directories By Inflated Size
- Top Files By Inflated Size
- Estas heurísticas ayudan a estimar si el trabajo de path-walk también influirá en el tamaño de ese repositorio
1 comentarios
Comentarios de Hacker News
Si se preguntan dónde está el nuevo comando
git-survey, todavía no ha entrado a git.gitEl autor usa el fork de Git de Microsoft, y este comando se agregó este verano: https://github.com/microsoft/git/pull/667
full-name-hashypath-walktambién parecen existir solo en el fork o solo en Git HEADNo se pueden encontrar en las páginas del manual ni en el registro de cambios de la 2.47
Me da curiosidad qué significa eso de que “la gente en Europa ni siquiera puede clonar el repositorio por su tamaño”
Suena raro que un repositorio grande no se pueda bajar en Europa pero sí en otros lugares, y parece que seguirían sin poder clonarlo hasta que se apliquen los cambios del lado del servidor
La frase “seguíamos haciendo push de archivos completos una y otra vez” parece estar cortada a la mitad, y mientras leía el artículo los GIF distraían demasiado y seguían apareciendo incluso en modo lectura
Si se escribiera de forma más completa, sería algo como: “Alguien quería clonar el repositorio completo en su laptop para usarlo mientras viajaba por Europa, pero en ese momento no pudo porque el disco estaba lleno. Después limpió espacio en disco y sí pudo clonarlo”
No creo que signifique un problema grande de que Europa no pueda manejar un archivo de 180 GB
Más aún si la persona estaba usando una VPN, especialmente una de esas terribles trampas de “SSL VPN”, y todavía son sorprendentemente comunes los servidores con software que maneja mal la latencia o con escalado de ventana roto
Si la mayoría de los desarrolladores están cerca del servidor y usan conexiones con buen ancho de banda, este tipo de problema no se nota tanto
La clonación de Azure DevOps ni de cerca logra saturar una conexión cableada de gigabit, y como tarda demasiado, al final se detiene por mi lado o por el de Azure DevOps y termino rindiéndome
Por suerte, casi todo el trabajo lo hago con una clonación superficial dentro de Codespaces, así que no es un gran problema, y ojalá arreglen nuestro repositorio también si tiene un crecimiento de tamaño parecido al del repositorio 1JS de este artículo
Actualización: fue un error absurdo, y el nombre del archivo no incluye la ruta completa
Puede que la explicación haya quedado enterrada entre los GIFs, pero aquí los últimos 16 caracteres son distintos: es un ejemplo de que cambiaron
repo/packages/foo/CHANGELOG.mdy Git, al preparar el push, generó un delta comparándolo conrepo/packages/bar/CHANGELOG.mdTambién vale la pena revisar la cover letter del API
path-walk: https://lore.kernel.org/all/pull.1786.git.1725935335.gitgitg...El ejemplo del blog no es del todo claro, pero Git básicamente tomaba todas las versiones de todos los archivos del repositorio, metía los últimos 16 bytes de la ruta en una tabla hash y agrupaba para compresión delta los que esperaba que fueran distintas versiones del mismo archivo
foo/CHANGELOG.mdybar/CHANGELOG.mddel artículo solo tienen 13 caracteres, así que el ejemplo tal cual no cuadra, y hay que imaginar rutas con un sufijo común más largoEsa parte se corrigió con la opción
--full-name-hash, que ahora compara la ruta completa en lugar de 16 bytesEl aumento del tamaño de ventana que mencionan después se parece más a un hack para esquivar una mala agrupación de archivos que a un arreglo real
Sigue dándole al compresor una entrada horrible y lo sortea usando una cantidad enorme de memoria, así que me confundió que lo presentaran como solución
La parte realmente interesante es el API path-walk y
--full-name-hashAquí me queda la duda de si el nombre de archivo se refiere a la ruta, o si Git está eligiendo cualquier archivo con el mismo nombre para generar el delta
También me gustaría saber si hay algún patrón en cómo elige otros archivos
Se puede ver en el mensaje del primer commit del PR de
--full-name-hash: https://github.com/git-for-windows/git/pull/5157/commits/d5c...Citando:
"/CHANGELOG.json"tiene 15 caracteres, lo genera la herramienta beachball, y solo el último carácter del directorio padre puede distinguir entre distintas versiones de este archivo, y aun así solo se reflejan sus 2 bits más altosSi ese carácter es una letra, siempre colisiona, y la ruta
"/CHANGELOG.md"tiene un problema parecido, aunque hay un poco más de margen para diferenciarse desde el directorio padreEl algoritmo de agrupación da menos peso a cada carácter cuanto más a la derecha se aleja del nombre:
hash = (hash >> 2) + (c << 24)El hash es de 32 bits, y como cada carácter de 8 bits de la ruta completa se va agregando a los 8 bits más altos del hash mientras los bits existentes se desplazan 2 bits a la derecha, al final solo influyen los últimos 16 caracteres
Aquí se puede ver lo que pasa en la práctica: https://go.dev/play/p/JQpdUGXdQs7
Está portado a Go y compara los valores finales de
"aaa/CHANGELOG.md"y"zzz/CHANGELOG.md"; si pruebas distintos valores paraaaayzzz, se ve lo poco que afectan al valor finalLos últimos 16 caracteres de
CHANGELOG.mdson todo el nombre del archivo, y si se interpreta así también se explica por qué la soluciónfilepathwalkresuelve el problemaPero si de verdad se basa solo en los últimos 16 caracteres del nombre del archivo y no en la ruta completa, parecería que este problema debería haber sido mucho más común, sobre todo en monorepos
Probablemente la confusión venga de que usaron
fooybarcomo metavariables, y los nombres reales de los paquetes seguramente tenían un sufijo común, algo comofirstPkg,secondPkg"/CHANGELOG.md"ya ocupa 13 caracteres, así que con apenas 3 caracteres de sufijo común ya se produce una colisión de 16 caracteresLo probé enseguida con
nixpkgs, y clonado tal cual desde GitHub pesa unos 5 GBLa primera opción que menciona el artículo,
--window 250, lo redujo a 1.7 GB, y la nueva opción--path-walkdel fork de Git de Microsoft fue menos efectiva, dejándolo en 1.9 GB en totalComo ambas quedan por debajo de la mitad del tamaño inicial, estaría bueno que GitHub ofreciera alguna forma de ejecutar este tipo de proceso, y mejor todavía si la gente empezara a alojar repositorios de una manera que permitiera controlar estas cosas directamente
Aparece Derrick Stolee, quien investigó los cambios necesarios del artículo y los lanzó
Si te interesan la estructura interna de Git, reducir el tamaño de los clones de Git en local y en CI, Derrick ha escrito muchos artículos excelentes en el blog de GitHub: https://github.blog/author/dstolee/
Su sitio personal también vale la pena: https://stolee.dev/
Si alguien hizo commit por accidente de un binario grande, la forma de corregirlo sería ejecutar
filter-branchpara eliminar el binario problemático, enseñar y configurar a todos para que usen git-lfs para binarios, luego hacer un push forzado y devolver el entorno de trabajo de cada quien a un estado normal.No es ideal, pero es mejor que seguir dejando archivos grandes que ni se usan dentro de Git.
filter-branch, también existe BFG: https://rtyley.github.io/bfg-repo-cleaner/Como señaló otra persona, este problema trata de archivos pequeños que cambian con frecuencia, así que para ahorrar espacio basta con eliminar las versiones antiguas del historial y usar LFS de ahora en adelante.
Hackear Git suena divertido, pero da la impresión de que habría que preguntarse si no hay una forma de evitar meter 2,500 paquetes en un monorepo desde el principio.
Mientras más grande se vuelve la base de código, más difícil es esperar que la gente no reinvente la rueda, ya sea porque no sabe que ese código existe o porque teme romper usos existentes al modificarlo para cubrir su caso de uso.
Por sensación, toma menos tiempo pasar de 100 módulos a 200 que de 50 a 100.
Parece un problema totalmente creado por ellos mismos.
Últimamente he estado siguiendo los avances relacionados con monorepos y escalabilidad a partir de las presentaciones de GitMerge que publicó GitButler, y Microsoft, GitHub y GitLab están sacando cosas muy buenas.
Me gustaría entender mejor cómo se relacionan la verificación de los últimos 16 caracteres y la verificación de la ruta completa con la compresión delta, los índices de pack y los índices multi-pack.
Da gusto ver que Microsoft sí usa Azure DevOps directamente.
Parecía que cada vez más servicios de Azure solo ofrecían conectores nativos para GitHub, así que pensé que Azure DevOps iba camino al abandono.
Debe ser una gran ventaja en un proyecto así, dentro de una empresa de este tamaño, tener al alcance de la mano a alguien que conoce tan bien la estructura interna de Git y puede ayudar.