Go-Compiler “Optimierungen”

Go-Compiler "Optimierungen"

Über einen Blog Artikel bin ich auf die Idee gekommen mir die Build-Zeiten des Go-Compilers doch mal etwas anzuschauen. Im Artikel wird festegstellt, dass bei einem Go-Build das Kommando hg stat im Hintergrund aufgerufen wird und dass dies vermutlich am längsten dauert und die Build-Zeit verlängert:

Turns out go build will run hg stat for every build, which go run skips, and that was the slowest part of the process.

Ab Go Version 1.18 werden in der Tat genau diese Versionsinformationen, die mit dem angegebenen Kommando ermittelt werden, in das resultierende Go-Binary aufgenommen. Wie beschrieben passiert das nicht, wenn der Compiler über go run aufgerufen wird (siehe z. B.). Selbstverständlich passiert das nicht nur bei Mercurial sondern auch bei den anderen gängigen Versionsverwaltungswerkzeuge:

Release Notes Go 1.18

The go command now embeds version control information in binaries. It includes the currently checked-out revision, commit time, and a flag indicating whether edited or untracked files are present. Version control information is embedded if the go command is invoked in a directory within a Git, Mercurial, Fossil, or Bazaar repository, and the main package and its containing main module are in the same repository. This information may be omitted using the flag -buildvcs=false.

Möchte man die Information aus dem Binary auslesen kann dann anschließend das go version Kommandozeilenwerkzeug verwendet werden:

go version -m -v <binary>

Da das Feature allerdings erst in Go 1.18 eingeführt wurde, kam bei mir gleich die Frage auf:

Wie hat sich die Build-Zeit des Go-Compilers über die Zeit verändert?

Rein aus Interesse wollte ich herausfinden, wie sich denn die Build-Zeiten von Go über die unterschiedlichen Versionen verändert haben.

Der Plan sah so aus, dass ich ein Hello-World Beispiel einfach mit unterschiedlichen Go-Versionen kompiliere und dann die Zeiten miteinander vergleiche. Vielleicht ergibt sich ja was…

Mit welchen Go-Version testen?

Die nächste Frage, die sich daraufhin gestellt hat war: Mit welchen Go-Versionen soll die Analyse stattfinden? Am Besten doch einfach mit Allen!

Mit Docker ist das natürlich auch recht einfach zu lösen. Über DockerHub werden alle bisher veröffentlichten Go-Versionen als Image zur Verfügung gestellt. Mit dieser Liste wollte ich also testen und das Hello-World Beispiel jeweils übersetzen und die Zeit messen.

Begonnen hatte ich mit der Abfrage der Images über wget und einer entsprechenden Auswertung mittels jq. Das hat aber dann schnell meine Schmerzgrenze beim Bash-Skripting erreicht und ich habe nach einer kurzen Suche das Hub-Tool von Docker selbst gefunden. Ein Kommandozeilenwerkzeug, das selbst in Go geschrieben ist, mit dem man recht einfach alle Docker-Image-Versionen abfragen kann.

Das Tool lässt sich super einfach installieren:

go install github.com/docker/hub-tool@latest

und dann nutzen:

hub-tool tag ls --all golang

Somit hatte ich also eine Liste der aller Go-Versionen, die ich dann doch wieder mit Hilfe von jq, cut und grep soweit zusammengedampft habe, dass ich nur noch die “echten” Releases hatte. Also keine Beta oder Sonstwas-Versionen.

...
1.14.7 
1.14.8 
1.14.9 
1.15 
1.15.0 
1.15.1 
1.15.10 
1.15.11 
1.15.12 
1.15.13 
1.15.14 
...

Das waren dann immer noch 228 Versionen!

Versuchsaufbau - erster Lauf

Nachdem ich also die Liste hatte konnte ich den ersten Versuch starten und über eine Schleife jeweils das Docker-Image herunterladen und die Anwendung darin kompilieren.

docker run --rm -v $(pwd):/go/src golang:$version /bin/bash -c 'time go build main.go';

Ausgeführt über alle Versionen brachte mich im ersten Schritt zu folgender Grafik:

Compiler Zeiten von Go

Hello World Compilezeiten

WOW! ein Clickbait Bild! ;-)

Go Compiler ab Version 1.20 um Faktor 18 langsamer!

Ist natürlich Quatsch!, aber in der Grafik zugegebenermaßen beeindruckend. Der Grund ist recht einfach gefunden. Ab Version 1.20 werden keine pre-compiled Packages für die Standardbibliothek mehr ausgeliefert.

Release Notes Go 1.20

The directory $GOROOT/pkg no longer stores pre-compiled package archives for the standard library: go install no longer writes them, the go build no longer checks for them, and the Go distribution no longer ships them. Instead, packages in the standard library are built as needed and cached in the build cache, just like packages outside GOROOT. This change reduces the size of the Go distribution and also avoids C toolchain skew for packages that use cgo.

Das führt dazu, dass erstmals alle benötigten Teile der Standardbibliothek mit übersetzt werden müssen. Dieser ‘Overhead’ ist bei dem kleinen Hello World Beispiel recht massiv und wirkt sich sehr stark auf die Zeit aus.

Dieser ‘Overhead’ fällt natürlich nur einmalig an! Lokal in der Entwicklungsumgebung spielt das sicher keine Rolle. Vor Allem wenn es nicht wie auf meinem Test-Rechner 5s dauert. In CI/CD-Pipelines fällt dieser ‘Overhead’ aber potenziell immer an.

Sind die Zeiten vergleichbar?

Um die Zeiten wieder ‘vergleichbarer’ zu machen habe ich einfach vor der Zeitmessung die Standardbibliothek übersetzt und somit eine ähnliche Grundlage für die Messungen geschaffen:

go install std

Ohne Übersetzen der Standardbibliothek ergab sich dann folgende Grafik:

Compiler Zeiten von Go (Prebuild)

Hello World Compilezeiten (Prebuild)

Das relativiert die Ergebnisse doch schon gewaltig. Man sieht recht schön, dass die Build-Zeiten eigentlich seit Version 1.9 recht konstant geblieben sind. Ausreisser gib es immer wieder, was aber sicher auch an meinem ‘Test-Setup’ liegen kann.

Beispielzeiten:

1.16.7       93 ms
...
1.17        100 ms
...
1.18        137 ms
...
1.21.6      100 ms

Beachtlich ist der Unterschied von ca. 5 Sekunden bei einem Build auf meiner Maschine.

1.21.6      100 ms (precompiled)     5372 ms (clean)

Vielleicht könnte man in eingenen Images, die zum Bauen verwendet werden auch einfach die Standardbibliothek vorab übersetzen, dann muss das nicht in jedem Projekt neu gemacht werden!

Beispielrechnung: 5s * 10 (mal am Tag gebaut) * 10 (Projekte) * 248 (Arbeitstage) = 124000 s = ~34 Stunden - WOW! - Ob jetzt realistisch oder nicht -> kleiner Aufwand -> Wirkung

Ziel nicht aus den Augen verlieren!

Ok, das eigentliche war doch das buildvcs Flag. Wie ändern sich nun hier die Build-Zeiten? Da das Flag erst in Version 1.18 eingeführt wurde, verkürzt sich dementsprechend auch die Messreihe.

Verglichen habe ich die Build-Zeit mit einem Precompiled-Build mit Standardeinstellung buildvcs und mit buildvcs=false. Im ersten Schritt mit vorhandenem .git Verzeichnis.

Die Grafik zeigt erstmal den ‘Overhead’ (Splate G sind Millisekunden):

Compiler Zeiten von Go (BuildVCS)

Hello World Compilezeiten (BuildVCS ja/nein) - Spalte G sind ms

Also eigentlich “kein Unterschied”. Ich würde auf ‘Messungenauigkeit’ tippen. Alles hier mit Git getestet, eventuell ist es bei Mercurial tatsächlich etwas Anderes.

Go-Version  Prebuild time       Prebuild ohne buildvcs
...
1.19.2      167 ms              109 ms
...
1.19.3      129 ms              129 ms
...
1.21.6      100 ms               98 ms

Dazu habe ich noch eine zweite Messreihe erstellt ohne .git Verzeichnis, da schwankten die Zeiten auch soweit, dass man nicht wirklich was sagen kann. Es scheint aber kein großen Unterschied zu machen.

BuildVCS scheint in meinem Setup mit git keinen großen Unterschied zu machen.

Auf einen Vergleich von Mercurial und Git habe ich verzichtet. Ob das einen Mehrwert liefern würde weiß ich nicht…

Wie hat sich die Dateigröße verändert?

Interessiert hat mich am Ende noch die Anwendungsgröße. Wie hat sich die Dateigröße über die Go-Versionen hinweg verändert?

Auch hier hat sich wenig getan. Die Dateigößen schwanken etwas. In Version 1.8 waren es mal 1,5 MB. Ansonsten gewegte sich die Größe so um die 2 MB. Aktuell sind es bei mir ca. 1,8 MB.

Compiler Zeiten von Go (Filesize)

Hello World Compilezeiten (Filesize)

Fazit

Schlußendlich muss man sagen, das mich das Ergebnis beim pre-compile schon etwas überrascht hat. Auch ein Projektsetup zu finden, dass in allen Go-Versionen 1:1 funktioniert, war auch etwas überraschend. Die Einführung des Module-Supports oder die unterschiedlichen Compiler-Flags, haben das ganze doch etwas komplizierter gemacht, wie ursprungs gedacht.

Klar ist auch, dass die Welt sich bei einem “Hello World”-Beispiel auch anders dreht wie bei einer größeren Anwendung. Was man sich sicher merken kann:

  • Go hat mittlerweile (Stand Jan 2024) 228 Versionen veröffentlicht!
  • go run compiliert potentiell etwas flotter, das Binary enthält aber keine Versionsinformation.
  • PreCompiling der Standardlib bringt tatsächlich was. Eventuell fällt es bei größeren Projekten nicht ins Gewicht, muss aber trotzdem nicht sein.
  • Durch die unterschiedlichen Default-Einstellungen der Go-Umgebung, musste immer wieder etwas unterschiedlich konfiguriert werden. Aber der Code war immer der selbe (gut, ist ja auch ein “Hello World” in diesem Beispiel. Das Kompatibilitätsversprechen habe ich damit nicht bewiesen).

Compatibility is at the source level. Binary compatibility for compiled packages is not guaranteed between releases. After a point release, Go source will need to be recompiled to link against the new release.

Der nächste Schritt sollte jetzt noch ein Vergleich einer “größeren Anwendung” sein. Gibt es Unterschiede wenn man z. B. einen Microservice compiliert? Fallen die Unterschiede wirklich ins Gewicht… wahrscheinlich nicht, aber wer weiß…

24.01.2024

 

Der Author auf LinkedIn: Kristian Köhler und Mastodon: @kkoehler@mastodontech.de

Kennen Sie schon das Buch zum Thema?

Der praktische Soforteinstieg für Developer und Softwarearchitekten, die direkt mit Go produktiv werden wollen.

  • Von den Sprachgrundlagen bis zur Qualitätssicherung
  • Architekturstil verstehen und direkt anwenden
  • Idiomatic Go, gRPC, Go Cloud Development Kit
  • Cloud-native Anwendungen erstellen
Microservices mit Go Buch

zur Buchseite beim Rheinwerk Verlag Rheinwerk Computing, ISBN 978-3-8362-7559-0 (als PDF, EPUB, MOBI und Papier)

Kontakt

Source Fellows GmbH

Source Fellows GmbH Logo

Lerchenstraße 31

72762 Reutlingen

Telefon: (0049) 07121 6969 802

E-Mail: info@source-fellows.com