7 Punkte von GN⁺ 2024-10-28 | 1 Kommentare | Auf WhatsApp teilen
  • Das große JavaScript-Monorepo 1JS von Microsoft wuchs auf 1.000 monatlich aktive Nutzer, etwa 2.500 Pakete und rund 20 Millionen Zeilen Code an; zuletzt erreichte die Clone-Größe 178 GB, sodass einige Entwickler das Repository kaum noch klonen konnten
  • Ein früher Engpass war die Struktur, bei der sich Beachball-Änderungsdateien zu Zehntausenden in einem Ordner ansammelten; als die Zahl auf 40.000 Dateien stieg, entstand bei jedem neuen Datei-Add ein großes tree object
  • Die größere Ursache war, dass der bisherige Packing-Code von Git in Situationen mit wiederholt aktualisierten langen Dateinamen wie CHANGELOG.md und CHANGELOG.json nur die letzten 16 Zeichen des Dateinamens zur Auswahl von Delta-Zielen betrachtete
  • git repack -adf --window=250 half zwar, aber als Microsoft im Git-Fork 2.47.0.vfs.0.2 git repack -adf --path-walk ausführte, schrumpfte das lokale Repository von 178 GB auf 5 GB
  • Wenn sich in großen Monorepos Dateien mit langen Namen häufig ändern, sollte man pack.usePathWalk true und den neuen Befehl git survey prüfen; die zugehörigen Änderungen sollen auch in Git upstream einfließen

Größe und Engpässe des 1JS-Monorepos

  • Das große interne Monorepo 1JS von Microsoft ist nicht nur beim Speicherbedarf groß, sondern auch beim Umfang von Code und Beiträgen
    • mehr als 1.000 monatlich aktive Nutzer
    • etwa 2.500 Pakete
    • rund 20 Millionen Zeilen Code
  • Ein kürzlich geklontes Repository hatte eine Größe von 178 GB, und einige Entwickler in Europa konnten das Repository wegen seiner Größe nicht klonen
  • Beim ersten Clone lag es noch bei etwa 1 bis 2 GB, einige Monate später bei rund 4 GB, und danach wuchs es weiter

Lesson #1: Nicht Tausende Dateien in einem Ordner ansammeln

  • git-sizer machte große Blobs und das Problem mit Beachball-Änderungsdateien sichtbar
  • Große Blobs entstehen, wenn versehentlich Binärdateien committet werden; mit Funktionen wie Check-in-Größenlimits in Azure DevOps lässt sich das verhindern
    • Dateien, die bereits in der Historie gelandet sind, lassen sich nachträglich nicht leicht entfernen
  • 1JS nutzte Beachball change files, um semver-Bereichserhöhungen von Paketen zu automatisieren
    • Der Einsatzzweck ist ähnlich wie bei Changesets
    • Das Ziel ist, Versionsanhebungen von Paketen ähnlich wie bei semantic-release zu automatisieren
  • Weil die Änderungsdateien nicht gelöscht wurden, sammelten sich bis zu 40.000 Dateien in einem Ordner; in diesem Zustand erzeugte das Hinzufügen neuer Dateien große tree objects
  • Es gab zwei Gegenmaßnahmen
    • Ein Beachball PR änderte das Verhalten so, dass statt einer Datei pro Paket mehrere Änderungen in einer einzigen Änderungsdatei gesammelt werden
    • Es wurde eine Pipeline geschrieben, die den Ordner mit den Änderungsdateien regelmäßig automatisch bereinigt

Lesson #2: Das Problem mit dem versioned-Branch und dem Packing von Changelogs

  • Der Versionsverwaltungs-Workflow von 1JS hält einen versioned-Branch als Spiegel von main
    • Er speichert die tatsächlichen Paketversionen und vermeidet so Git-Konflikte in main
    • Außerdem lässt sich die Zuordnung zwischen als NPM-Paket veröffentlichten semver-Versionen und Git-Commits exakt nachvollziehen
  • Der versioned-Branch enthielt hauptsächlich Commits, die nur Inhalte in CHANGELOG.md- und CHANGELOG.json-Dateien ergänzten, wuchs aber so stark an, dass Clones immer schwieriger wurden
  • Das Repository wuchs seit 2021 zwar natürlich jedes Jahr um Hunderttausende Codezeilen und Hunderte Entwickler, doch als die Wachstumsrate die des Office-Monorepos von Microsoft überstieg, wurde eine gesonderte Ursache vermutet
  • Entwickler, die an Git-Funktionen für große Monorepos wie shallow checkout und sparse index gearbeitet hatten, stellten fest, dass beim Abrufen des versioned-Branchs 125 GB zusätzliche Git-Daten gefetcht wurden

Wie der Packing-Code von Git die falschen Deltas erzeugte

  • Die Ursache war, dass alter Packing-Code in Git vor dem Komprimieren beim Vergleichen von Dateien nur die letzten 16 Zeichen des Dateinamens betrachtete
  • Normalerweise pusht Git nur den Diff geänderter Dateien, aber durch dieses Packing-Problem wurden CHANGELOG.md-Dateien aus unterschiedlichen Paketen miteinander verglichen
  • Wenn zum Beispiel repo/packages/foo/CHANGELOG.json geändert wurde, konnte Git bei der Vorbereitung des Pushs ein Diff gegen repo/packages/bar/CHANGELOG.json erzeugen
  • Dadurch wurden in vielen Fällen immer wieder ganze Dateien erneut gepusht, und manche dieser Dateien konnten mehrere Dutzend MB groß sein
  • Details zum Problem sind in Stolees Beitrag auf der Git-Mailingliste zusammengefasst

Ergebnisse mit repack und path-walk

  • Zunächst verbesserte git repack -adf --window=250 die Komprimierung der Pack-Dateien durch ein größeres Fenster, wodurch die Repository-Größe deutlich sank
  • Danach ergänzte git-for-windows PR #5171 statt des bisherigen standardmäßigen Commit-Durchlaufs eine Packing-Methode, die Git-Pfade durchläuft
  • Im Git-Fork von Microsoft, Microsoft git git version 2.47.0.vfs.0.2, wurde mit der neuen Methode geklont und anschließend repack ausgeführt
    • Ausgeführter Befehl: git repack -adf --path-walk
    • Ergebnis: Die Repository-Größe sank von 178 GB auf 5 GB
  • Die neue Konfigurationsoption sorgt auch beim Push dafür, dass die richtigen Deltas erzeugt werden
    • git config --global pack.usePathWalk true
  • Entwickler, die git version 2.47.0.vfs.0.2 verwenden, können nach dem lokalen Clone repack ausführen und mit dem neuen path-walk-Push-Algorithmus die Wachstumsrate senken

Auswirkungen auf GitHub, Azure DevOps und Git upstream

  • GitHub führt regelmäßig repack und Git Garbage Collection aus, aber die aktuelle Packing-Methode von GitHub kann Deltas für Dateien wie CHANGELOG.md und CHANGELOG.json derzeit nicht korrekt berechnen
  • Betroffen sein können auch Fälle, in denen sich lange Dateinamen mit denselben letzten 16 Zeichen über längere Zeit häufig ändern
    • Als Beispiel werden große String-Dateien für i18n genannt
  • Das von 1JS genutzte Azure DevOps führt ein solches repack bislang noch nicht aus; es wird aber an Maßnahmen gearbeitet, um auch die Größe serverseitiger Repositories zu reduzieren
  • Die zugehörigen Änderungen sollen auch in Git upstream einfließen

Was man in großen Monorepos prüfen sollte

  • Wenn in großen Monorepos CHANGELOG.md oder andere Dateien mit relativ langen Namen wiederholt aktualisiert werden, sollte man die Änderungen rund um path-walk im Blick behalten
  • Der neue Befehl git survey bietet Heuristiken, um den Zustand eines Repositorys zu beurteilen
    • Top Files By Disk Size
    • Top Directories By Inflated Size
    • Top Files By Inflated Size
  • Diese Heuristiken helfen einzuschätzen, ob die path-walk-Arbeit auch die Größe dieses Repositorys beeinflussen kann

1 Kommentare

 
GN⁺ 2024-10-28
Meinungen auf Hacker News
  • Wo der neue Befehl git-survey zu finden ist: Er ist noch nicht in git.git enthalten.
    Der Autor verwendet Microsofts Git-Fork, und dieser Befehl wurde in diesem Sommer hinzugefügt: https://github.com/microsoft/git/pull/667

    • full-name-hash und path-walk scheinen ebenfalls nur im Fork oder nur in Git HEAD vorhanden zu sein.
      In den Manpages oder im 2.47-Changelog sind sie nicht zu finden.
  • Ich frage mich, was mit „Leute in Europa können das Repository wegen seiner Größe nicht einmal klonen“ gemeint ist.
    Es wirkt seltsam, dass man ein großes Repository in Europa nicht herunterladen kann, anderswo aber schon, und es klingt so, als könne man es bis zur serverseitigen Änderung weiterhin nicht klonen.
    Der Satz „die gesamte Datei immer wieder neu gepusht“ scheint mittendrin abgeschnitten zu sein, und beim Lesen ist das GIF sehr ablenkend; selbst im Lesemodus bleibt es sichtbar.

    • Ich habe diesen Satz eher als Anekdote gelesen.
      Vollständiger formuliert wäre es wohl etwa: „Jemand wollte das gesamte Repository auf einen Laptop klonen, um es auf Reisen in Europa zu nutzen, konnte es damals aber nicht, weil die Festplatte voll war. Später wurde die Festplatte aufgeräumt, und das Klonen war möglich.“
      Ich glaube nicht, dass damit ein großes Problem gemeint ist, dass Europa keine 180-GB-Dateien bewältigen könne.
    • „Europa“ ist hier wahrscheinlich eine Umschreibung für hohe Latenz und geringe Bandbreite.
      Besonders dann, wenn die betroffene Person ein VPN nutzt, insbesondere einen dieser furchtbaren „SSL-VPN“-Workarounds; Software mit schlechtem Latenzverhalten oder Server mit kaputtem Window Scaling sind immer noch überraschend verbreitet.
      Wenn die meisten Entwickler nahe am Server sitzen und Verbindungen mit guter Bandbreite nutzen, fallen solche Probleme kaum auf.
    • Ich arbeite in Australien für ein anderes internes Team bei Microsoft und nutze ein großes Monorepo in Azure DevOps; vollständiges Klonen ist praktisch kaum möglich.
      Das Klonen aus Azure DevOps lastet eine kabelgebundene Gigabit-Verbindung bei Weitem nicht aus, und weil es so lange dauert, bricht es am Ende entweder bei mir oder auf der Azure-DevOps-Seite ab, sodass ich aufgebe.
      Zum Glück erledige ich fast alles mit einem Shallow Clone in Codespaces, daher ist es kein großes Problem; falls es in unserem Repository eine ähnliche Größenexplosion gibt wie beim 1JS-Repository in diesem Artikel, hoffe ich, dass sie behoben wird.
    • Das Repository liegt vermutlich an der US-Westküste, sodass beim Klonen aus Europa jedes Mal der Atlantik überquert werden muss.
    • Es könnte auch in einem Land mit weniger gut ausgebauter Internetinfrastruktur liegen, zum Beispiel in Deutschland.
  • Update: Das war ein dummer Fehler, und der Dateiname enthält nicht den vollständigen Pfad
    Die Erklärung scheint zwischen den GIFs untergegangen zu sein, aber die letzten 16 Zeichen unterscheiden sich hier: Gemeint ist ein Beispiel, in dem repo/packages/foo/CHANGELOG.md geändert wurde, Git beim Vorbereiten des Pushs aber mit repo/packages/bar/CHANGELOG.md verglichen und daraus ein Diff erzeugt hat

    • Derrick erklärt es in diesem Cover Letter besser: https://lore.kernel.org/git/pull.1785.git.1725890210.gitgitg...
      Auch der Cover Letter zur path-walk-API ist lesenswert: https://lore.kernel.org/all/pull.1786.git.1725935335.gitgitg...
      Das Beispiel im Blog ist nicht besonders eindeutig, aber Git holte sich im Grunde alle Versionen aller Dateien im Repository, legte die letzten 16 Bytes des Pfads in eine Hash-Tabelle und gruppierte Dinge, von denen es annahm, dass es unterschiedliche Versionen derselben Datei seien, für die Delta-Kompression
      foo/CHANGELOG.md und bar/CHANGELOG.md im Artikel haben nur 13 Zeichen, daher passt das Beispiel so nicht; man muss sich Pfade mit einem längeren gemeinsamen Suffix vorstellen
      Dieser Teil wurde mit der Option --full-name-hash behoben, sodass nun nicht mehr 16 Bytes, sondern der gesamte Pfad verglichen wird
      Die anschließend erwähnte Vergrößerung der Window-Size ist eher ein Hack, um eine schlechte Dateigruppierung zu umgehen, und kein echter Fix
      Man gibt dem Kompressor weiterhin furchtbare Eingaben und umgeht das Problem mit enormem Speicherverbrauch; deshalb war es verwirrend, dass das als Lösung dargestellt wurde
      Der wirklich interessante Teil sind die path-walk API und --full-name-hash
    • Ich hätte mir gewünscht, dass der Artikel statt der „farbenfrohen“ Erzählung genauer erklärt, was passiert ist
      Ich frage mich, ob „Dateiname“ hier den Pfad meint oder ob Git beliebige Dateien mit demselben Namen auswählt und daraus ein Diff erstellt
      Außerdem würde ich gern wissen, ob es bei der Auswahl der anderen Datei ein Muster gibt
    • Es stimmt, dass der vollständige Pfad berücksichtigt wird
      Siehe die erste Commit-Message des PR zu --full-name-hash: https://github.com/git-for-windows/git/pull/5157/commits/d5c...
      Auszug: "/CHANGELOG.json" hat 15 Zeichen und wird vom beachball-Tool erzeugt; nur der letzte Buchstabe des Elternverzeichnisses kann die unterschiedlichen Versionen dieser Datei unterscheiden, und selbst davon gehen nur die oberen 2 Bits ein
      Wenn dieses Zeichen ein Buchstabe ist, kollidiert es immer; der Pfad "/CHANGELOG.md" hat ein ähnliches Problem, lässt aber etwas mehr Spielraum für Unterschiede im Elternverzeichnis
      Der Gruppierungsalgorithmus gewichtet jedes Zeichen umso geringer, je weiter es von rechts im Namen entfernt ist: hash = (hash >> 2) + (c << 24)
      Der Hash ist 32 Bit breit; jedes 8-Bit-Zeichen des vollständigen Pfads wird nacheinander zu den obersten 8 Bits des Hashes addiert, während die vorhandenen Hash-Bits um 2 Bit nach rechts geschoben werden, sodass am Ende nur die letzten 16 Zeichen den Hash beeinflussen
      Was tatsächlich passiert, sieht man hier: https://go.dev/play/p/JQpdUGXdQs7
      Ich habe es nach Go übertragen und die Endwerte von "aaa/CHANGELOG.md" und "zzz/CHANGELOG.md" verglichen; wenn man für aaa und zzz verschiedene Werte einsetzt, sieht man, wie wenig sie den Endwert beeinflussen
    • Der Dateiname enthält nicht zwangsläufig den vollständigen Pfad
      Die letzten 16 Zeichen von CHANGELOG.md sind der gesamte Dateiname, und so interpretiert erklärt das auch, warum die filepathwalk-Lösung das Problem behebt
      Wenn es aber wirklich nur auf den letzten 16 Zeichen des Dateinamens basiert und nicht auf dem vollständigen Pfad, hätte dieses Problem gerade in Monorepos viel häufiger auftreten müssen
    • Mich hat diese Stelle auch gestört
      Vermutlich haben die Metavariablen foo und bar verwirrt; die tatsächlichen Paketnamen hatten wahrscheinlich ein gemeinsames Suffix, etwa firstPkg, secondPkg
      "/CHANGELOG.md" allein hat schon 13 Zeichen, daher reichen bereits 3 Zeichen gemeinsames Suffix für eine 16-Zeichen-Kollision
  • Ich habe es direkt mit nixpkgs ausprobiert; wenn man es unverändert von GitHub klont, sind es etwa 5 GB
    Die erste im Artikel erwähnte Option, --window 250, reduzierte die Größe auf 1,7 GB; die neue Option --path-walk aus Microsofts Git-Fork war weniger effektiv und kam insgesamt auf 1,9 GB
    Beides ist weniger als die Hälfte der ursprünglichen Größe, daher wäre es schön, wenn GitHub solche Vorgänge ausführen könnte; noch besser wäre es, wenn Leute Hosting so anbieten würden, dass man so etwas selbst steuern kann

  • Im Artikel kommt Derrick Stolee vor, der die nötigen Änderungen untersucht und veröffentlicht hat
    Wenn dich Git-Interna sowie die Reduktion der Git-Klongröße lokal und in CI interessieren, hat Derrick viele hervorragende Beiträge im GitHub-Blog geschrieben: https://github.blog/author/dstolee/
    Auch seine persönliche Website ist einen Blick wert: https://stolee.dev/

  • Wenn man versehentlich eine große Binärdatei committet hat, kann man als Lösung filter-branch laufen lassen, um die problematische Binärdatei zu entfernen, allen beibringen und einrichten, für Binärdateien git-lfs zu verwenden, anschließend einen Force-Push machen und die Arbeitsumgebungen aller wieder in einen sauberen Zustand bringen.
    Ideal ist das nicht, aber besser, als eine große Datei, die niemand nutzt, weiter in Git zu behalten.

    • Für alle, denen filter-branch Angst macht, gibt es auch BFG: https://rtyley.github.io/bfg-repo-cleaner/
      Wie jemand anderes angemerkt hat, geht es bei diesem Problem um kleine, aber häufig geänderte Dateien. Um Speicherplatz zu sparen, sollte man also alte Versionen aus der Historie entfernen und künftig LFS verwenden.
    • Das Kernproblem sind nicht Binärdateien, die einmal hineinkommen und sich dann nicht mehr ändern, sondern kleine Binärdateien, die sich häufig ändern.
    • Der heutzutage empfohlene Ansatz ist filter-repo: https://github.com/newren/git-filter-repo
    • Es ist einfacher, Linus die Schuld zu geben.
  • An Git herumzuhacken mag Spaß machen, aber vielleicht gäbe es ja eine Möglichkeit, von Anfang an keine 2.500 Pakete in ein Monorepo zu packen.

    • Die Zahl der Codezeilen wächst meistens eher exponentiell.
      Je größer eine Codebase wird, desto schwerer kann man erwarten, dass vorhandene Räder nicht neu erfunden werden, weil man entweder nicht weiß, dass der Code existiert, oder Angst hat, beim Anpassen an den eigenen Anwendungsfall bestehende Nutzer zu brechen.
      Gefühlt dauert es weniger lange, von 100 auf 200 Module zu kommen, als von 50 auf 100.
    • Dann kann man eben 2.500 Git-Repositories separat haben, samt dem ganzen zugehörigen Overhead.
    • Genau das war auch mein erster Gedanke.
      Sieht komplett nach einem selbst geschaffenen Problem aus.
  • Ich verfolge die Entwicklungen rund um Monorepos und Skalierbarkeit, nachdem ich mir kürzlich die GitMerge-Vorträge von GitButler angesehen habe; von Microsoft, GitHub und GitLab kommt da eine Menge Gutes.
    Ich würde gern besser verstehen, wie das Problem mit der Prüfung der letzten 16 Zeichen und der Prüfung des vollständigen Pfads mit Delta-Kompression, Pack-Index und Multi-Pack-Index zusammenspielt.

    • Wenn das so gut für Open Source ist, frage ich mich, ob sie vorhaben, einen Merge Request zu öffnen, um die Custom-Git-Befehle in das offizielle Git zurückzuführen.
  • Schön zu sehen, dass Microsoft Azure DevOps selbst nutzt.
    Es wirkt zunehmend so, als würden immer mehr Azure-Dienste nur noch native Connectoren für GitHub anbieten, daher dachte ich, Azure DevOps werde eher liegen gelassen.

  • Bei einem Projekt in einem Unternehmen dieser Größenordnung ist es sicher ein großer Vorteil, jemanden in Reichweite zu haben, der die internen Strukturen von Git so gut kennt und helfen kann.

    • Eine enge Beziehung zu einer Organisation wie GitHub hilft sicher, aber jede Organisation mit so vielen Entwicklern kann die Kosten dafür rechtfertigen, hoch spezialisierte Consultants hinzuzuziehen, um ein derartiges Nischenproblem zu lösen.