Skip to main content
Switch Language

Guide zu C/C++: ccache für schnelle Projekte

A happy person surrounded by multiple monitors
Blog Team
Software Intensive Systems
September 3, 2019 | 8:00 am CDT
A happy person surrounded by multiple monitors
Blog Team
Software Intensive Systems

Dies ist der erste von zwei Beiträgen, in denen wir mehrere praktische Open-Source-Tools vorstellen, mit denen Sie Ihre Kompilierzeit verkürzen können. Der Schwerpunkt liegt dabei auf ccache.

Ein Großteil Ihrer Arbeit als Softwareentwickler besteht aus einer Abfolge wiederkehrender Entwicklungs-, Build- und Testzyklen: Sie entwerfen und schreiben neuen Code (oder ändern bestehenden Code), geben „make“ in Ihr Terminal ein oder drücken die Schaltfläche „Kompilieren“ in Ihrer IDE, führen das resultierende Programm und/oder die Tests aus und arbeiten dann wieder an Ihrer Codebasis.

Mit einer just-in-time-kompilierten oder interpretierten Sprache wie JavaScript, Perl oder Python, ist der „Build“ Schritt zum Glück trivial oder findet im Hintergrund statt. Möglicherweise gibt es diesen Schritt nicht einmal. Aber wenn Sie Ihren Code in einer kompilierten Sprache wie C++ oder C geschrieben haben, ist klar: Je größer und komplexer das Projekt, desto länger wird die Erstellung dauern – vor allem, wenn Sie an einem normalen Laptop und nicht an einer riesigen Workstation arbeiten. Ihr Computer fängt also an zu rödeln, und wenn Sie in der Zwischenzeit nicht gerade etwas anderes zu tun haben, bleibt Ihnen nichts anderes übrig, als zu warten, bis der Compiler seine Arbeit erledigt hat.

Natürlich können Sie dieses Problem, wie viele andere Leistungsprobleme auch, mit zusätzlicher Hardware lösen. Sie können einen leistungsfähigeren Rechner unter Ihren Schreibtisch stellen oder einen eigenen Buildserver für das gesamte Team einrichten. Aber Hardware kostet Geld, braucht Platz, verbraucht Strom und erzeugt Lärm und Wärme. Warum also nicht einfach die vorhandenen Ressourcen effizienter nutzen?

Hier kommen Tools ins Spiel!

Glücklicherweise gibt es mehrere nützliche Tools für diesen Zweck. Grob gesagt gibt es zwei Kategorien von Tools, die das Problem aus unterschiedlichen Richtungen angehen:

  1. Speichern Ihrer Buildartefakte, damit der Compiler nicht immer wieder die gleiche Aufgabe ausführen muss.
  2. Nutzen Sie ungenutzte Rechenleistung, indem Sie aus den Rechnern in Ihrem Büro einen verteilten Build-Cluster machen.

Ich bin ein Anhänger der Open-Source-Bewegung, deshalb konzentriere ich mich im Folgenden darauf. Der bekannteste Vertreter der Kategorie 1 ist ccache, und darum geht es in diesem Artikel.

ccache: ein C/C++ Compiler-Cache

Ursprünglich wurde ccache als Dienstprogramm für das Open-Source-Projekt Samba entwickelt. Es handelt sich um einen Compiler-Cache, der die Artefakte vergangener Kompilierungsläufe speichert, um nachfolgende Läufe zu beschleunigen. Das funktioniert ungefähr so: Wenn Sie versuchen, eine Quelldatei mit demselben Inhalt, demselben Compiler und denselben Flags neu zu kompilieren, wird das Produkt aus dem Cache abgerufen, anstatt in einem zeitaufwändigen Schritt neu kompiliert zu werden.

So funktioniert ccache

In diesem Abschnitt geht es darum, wie genau ccache funktioniert. Weitere Details finden sich in der offiziellen Dokumentation.

ccache arbeitet wie ein Compilerwrapper, d. h. seine externe Schnittstelle ähnelt der Ihres Compilers, und er übergibt diesem Ihre Befehle. Da ccache die Kommandozeilen-Flags untersuchen und interpretieren muss, kann es leider nicht mit beliebigen Compilern kombiniert werden; es ist derzeit für die Verwendung in Kombination mit GCC oder Clang, den beiden großen Open-Source-Compilerprojekten, konzipiert.

Sie rufen ccache genau wie Ihren normalen Compiler auf und übergeben Ihre lange Liste von Befehlszeilen-Argumenten wie gewohnt. Er arbeitet auf der Ebene der einzelnen Quelldateien, so dass ein Aufruf von ccache im Allgemeinen eine .cpp- oder .c-Datei in eine .o-Datei übersetzt. Für jede Eingabe-Quelldatei führt der Wrapper einen Suchlauf in seinem Cache durch. Wird ein Treffer erzielt, wird einfach das zwischengespeicherte Element geliefert. Im Falle eines Cachefehlers wird der eigentliche Compiler gestartet, der dann eine ganze Reihe von Befehlszeilen-Argumenten weitergibt. Von dort aus fügt er das resultierende Artefakt in den Cache ein.

Der Cache selbst ist ein normales Laufwerkverzeichnis. Ihm wird ein Kontingent zugewiesen, und sobald dieses Kontingent überschritten wird, werden die alten Einträge entfernt. Sie können festlegen, welches Verzeichnis der ccache für den aktuellen Build verwenden soll. So können Sie verschiedene Caches für verschiedene Projekte pflegen, damit die Kompilierung eines neuen Projekts B den Cache von Projekt A nicht belastet.

Ein Blick ins Innere

The CC++Developers guide to avoid office swordfights

Das Suchen von Cacheeinträgen nutzt einen eindeutigen Tag, also eine aus zwei Elementen bestehende Zeichenkette: einem Hashwert und der Größe der vorverarbeiteten Quelldatei. Der Hashwert wird berechnet, indem die für die Erstellung der Ausgabedatei relevanten Informationen durch den MD4 Message-Digest-Algorithmus laufen. Diese Informationen enthalten unter anderem folgenden Elemente:

  • die Identität des Compilers (Name, Größe, Zeit der Modifikation),
  • die verwendeten Compilerflags,
  • den Inhalt der Eingangs-Quelldatei,
  • den Inhalt der eingeschlossenen Headerdateien (und deren transitive Hülle).

Die rechte Grafik bildet den ungefähren Workflow ab.

Nach der Berechnung des Tagwertes prüft ccache, ob bereits ein Eintrag mit diesem Tag im Cache vorhanden ist. Wenn das der Fall ist, ist keine Neukompilierung notwendig. Praktischerweise merkt sich ccache nicht nur das Artefakt selbst, sondern auch die Konsolenausgabe, die der Compiler bei der Übersetzung dieses Artefakts ausgegeben hat – wenn Sie also eine zwischengespeicherte Datei abrufen, die zuvor Compilerwarnungen erzeugt hat, gibt ccache diese Warnungen erneut aus.

Zusätzliche Raffinesse mit dem Direktmodus

Während dieser Prozess in der Praxis bereits gut funktioniert, kann die Tatsache, dass jede Ausführung von ccache einen vollständigen Lauf des Präprozessors für die Quelldatei erfordert, sich negativ auf die Leistung auswirken. Dadurch kann der Präprozessor zum störenden Engpass werden. Um dieses Problem zu umgehen, implementiert ccache einen alternativen Direktmodus. Dieser ist zwar etwas komplexer, macht aber den obligatorischen Präprozessorlauf überflüssig. In diesem Modus berechnet ccache MD4-Hashes für jede einzelne Include-Header-Datei und speichert die Ergebnisse in einem sogenannten Manifest. Beim Cache-Suchlauf werden die Hashes der Quelldatei und aller Includes mit dem Inhalt des Manifests verglichen; wenn alle Hashes paarweise übereinstimmen, liegt ein Treffer vor. In den aktuellen Versionen von ccache ist der Direktmodus standardmäßig aktiviert.

Gilt MD4 als unsicher?

Wer sich mit Kryptographie auskennt, fragt sich vielleicht, warum die stark veraltete und kryptografisch gefährdete Hashfunktion MD4 verwendet wird. Die Antwort ist einfach: weil MD4 schnell ist und die kryptografische Stärke in unserem Fall nicht relevant ist. Schließlich geht es bei ccache nicht darum, die Fälschung sensibler Nachrichteninhalte durch einen böswilligen Angreifer zu verhindern. Vielmehr geht es darum, redundante Arbeitsschritte zu erkennen und zu vermeiden. Die Kombination eines 128-Bit-MD4-Hashes mit einem Typensuffix macht es hinreichend unwahrscheinlich, dass es in der Praxis jemals zu einer Tagkollision kommt2.

So verwenden Sie ccache in Ihrem Projekt

Im Folgenden wird davon ausgegangen, dass Ihr Entwicklungsrechner ein Linuxsystem ist, auf dem eine 64-Bit-Version von Debian/Ubuntu oder Fedora/CentOS/RHEL läuft. Wenn Sie eine andere Linuxdistribution oder ein anderes unixähnliches Betriebssystem wie BSD oder macOS verwenden, sind die Anweisungen analog, können sich aber in bestimmten Details wie den Pfaden unterscheiden. Dieser Inhalt gilt nicht für Windowsnutzer.

Installation von ccache

Installieren Sie einfach das Paket aus Ihrer Linuxdistribution. Geschafft!

In Debian/Ubuntu:

$ sudo apt install ccache

In Fedora/CentOS/RHEL:

$ sudo dnf install ccache

oder

$ sudo yum install ccache

So aktivieren Sie ccache in Ihrem Projekt

Das ccache-Paket enthält ein Systemverzeichnis, das mehrere symbolische Links enthält; diese Links sind nach den gängigen Compiler-Binärprogrammen (einschließlich gcc, clang und c++) benannt, verweisen aber auf den Compilerwrapper von ccache. Um ccache zu aktivieren, stellen Sie dieses Verzeichnis Ihrer PATH-Umgebungsvariable voran, damit nicht die reguläre Compiler-Binärdatei aufgerufen wird sondern der Wrapper. Kopieren Sie die folgende Zeile in Ihr Terminal oder fügen Sie sie zu Ihrer Projektkonfiguration hinzu. Wenn Sie sicher sind, dass Sie ccache für alles verwenden wollen, können Sie ihn auch in Ihre Shell-Konfigurationsdatei (~/.bashrc, ~/.zshrc usw.) aufnehmen. Beachten Sie, dass das ccache-Verzeichnis an erster Stelle stehen muss, weil das System den PATH von links nach rechts durchsucht.

In Debian/Ubuntu:

$ export PATH="/usr/lib/ccache:$PATH"

In Fedora/CentOS/RHEL:

$ export PATH="/usr/lib64/ccache:$PATH"

Ihr nächster Build wird nun mit ccache schneller erstellt. Da der Cache anfangs leer ist, werden Sie beim ersten Lauf noch keine Geschwindigkeitssteigerung feststellen, aber die nachfolgenden Builds profitieren dann vom Cache.

Standardmäßig legt ccache den Cache in ~/.ccache unterhalb Ihres Homeverzeichnisses ab und weist eine maximale Kapazität von 5 GB zu.

Optionaler Schritt: Cacheverzeichnis für ccache festlegen

Es empfiehlt sich, einen separaten Cache spezielle für Ihr Projekt einzurichten. Dies ist sinnvoll, wenn Sie an mehreren Projekten arbeiten und nicht möchten, dass Builds anderer Projekte Ihre wertvollen zwischengespeicherten Kompilationsartefakte verdrängen. Natürlich muss Ihre Festplatte groß genug für mehrere Caches sein3. Um einen Cache speziell für Ihr Projekt einzurichten, erstellen Sie einfach ein neues leeres Verzeichnis am gewünschten Ort (z. B. ~/Projects/myproject/ccache). Dann weisen Sie ccache dieses Verzeichnis über die Umgebungsvariable CCACHE_DIR zu:

$ export CCACHE_DIR="$HOME/Projects/myproject/ccache"

Optionaler Schritt: Cache konfigurieren

Es gibt viele verschiedene Einstellungen, um Ihren Cache zu konfigurieren oder zu optimieren. Eine ausführliche Übersicht finden Sie in der offiziellen Dokumentation.

Globale Einstellungen für alle Caches sollten in /etc/ccache.conf gespeichert werden. Sie können diese globalen Einstellungen für einzelne Caches außer Kraft setzen, indem Sie neue Werte in einer Datei namens ccache.conf im Cacheverzeichnis definieren. Wenn Sie beispielsweise die Kapazität des Cache Ihres Hauptprojekts verdoppeln möchten, editieren Sie ~/Projects/myproject/ccache/ccache.conf (wenn sie dort nicht vorhanden ist, erstellen Sie sie) und fügen Sie die folgende Zeile hinzu:

max_size = 10.0G

Optionaler Schritt für Fortgeschrittene: ccache mit Docker

Vielleicht verwenden Sie Docker, um relativ einfach eine konsistente vordefinierte Buildumgebung bereitzustellen. In einer solchen Konfiguration wird der Build innerhalb des Containers aufgeführt.

Um effektiv zu sein, sollte Ihr Compilercache relativ langlebig und beständig sein. Sie sollten also den Zustand des Cache nicht mit dem Zustand des Containers koppeln. Deshalb sollten Sie das Cacheverzeichnis außerhalb des Containers halten und es in den Dateisystembaum des Containers einbinden. Im folgenden Beispiel hängen wir ihn an einem festen Ort als /ccache an. Beachten Sie, dass dies nur eine von mehreren möglichen Vorgehensweisen ist.

Editieren Sie Ihre Dockerdatei und lassen Sie den ccache im Container installieren. Bei auf Debian, Ubuntu oder deren Derivaten basierten Containern müssen Sie in etwa Folgendes schreiben:

RUN apt-get install -y ccache

Um die Umweltvariable zu definieren, die ccache einem Cacheverzeichnis zuordnet, fügen Sie der Dockerdatei die folgende Zeile hinzu:

ENV CCACHE_DIR "/ccache"

Anschließend erstellen Sie Ihr Containerimage neu.

Um den Build auszuführen, führen Sie Docker wie gewohnt aus, aber geben Sie in der Befehlszeile an, dass Ihr Cacheverzeichnis an /ccache gebunden werden soll:

$ docker run (...) --volume=$HOME/Projects/myproject/ccache:/ccache (...)

Ein Beispiel aus der Praxis

In einem Hauptprojekt unterstützte unser Team kürzlich einen Kunden bei der Entwicklung der Steuerungssoftware für eine Produktlinie medizinischer Geräte. Die Codebasis unseres Projekts war hauptsächlich in C++ geschrieben und umfasste insgesamt weit über eine halbe Million Codezeilen. Unser Buildsystem verwendet CMake und Ninja4, das den Build automatisch für die Anzahl der verfügbaren Prozessorkerne parallelisiert. Die Buildumgebung wird mit Docker containerisiert.

Auf meinem Laptop braucht der erste Kompilierungslauf nach dem Checkout beinahe 45 Minuten. Ninja ist ausgereift genug, um die Abhängigkeiten zwischen den Quelldateien und Buildartefakten nachzuverfolgen. Deshalb erstellt es normalerweise nur die transitive Hülle derjenigen Artefakte neu, die von den Änderungen betroffen sind. Doch bei jedem Wechsel der Arbeitskopie zwischen Entwicklungszweigen werden wesentliche Teile des Quellbaums komplett neu kompiliert.

Ich habe den Build mit einem sauberen Quellbaum und drei unterschiedlichen Konfigurationen durchgeführt:

The CC++Developers guide to avoid office swordfights
  • ohne ccache,
  • mit ccache und einem leeren Cache,
  • mit ccache und einem aufgewärmten Cache.

Alle Buildkonfigurationen wurden jeweils zehnmal gemessen. Die Zeiten werden jeweils als Mittelwerte dargestellt; die Fehlerbalken stellen eine Standardabweichung dar. Die Ergebnisse sehen Sie links.

Der Nachteil dabei ist, dass der ccache einen kleinen Overhead von etwa 5 % verursacht, wenn der Cache kalt ist. Der Vorteil allerdings ist, dass die Zeitersparnis bei aufgewärmtem Cache signifikant ist. Von den verbleibenden drei Minuten entfällt die meiste Zeit auf Aufrufe an den Linker, dessen Arbeit nicht von ccache zwischengespeichert werden kann.

Verbesserungsmöglichkeiten

ccache ist ein tolles Tool, das Zeitersparnis bringt. Dennoch gibt es Verbesserungsmöglichkeiten. Ein Nachteil von ccache ist, dass es auf der Granularitätsebene ganzer Quelldateien arbeitet. Er kann daher Fälle nicht richtig bearbeiten. Wenn Sie beispielsweise nur einen Kommentar in einem weit verbreiteten Header ändern, würde diese Änderung überhaupt keine Neukompilierung erfordern.

Eine intelligente Cachinglösung würde ihre Entscheidungen nicht auf den reinen Quellcode stützen, sondern auf den abstrakten Syntaxbaum (AST), der beim Parsen des Codes entsteht. Eine Änderung in einer Codezeile, die sich nicht auf den AST auswirkt, kann getrost ignoriert werden. Dies senkt die Anzahl vermeintlicher Cachemisses erheblich. Dieses Thema ist Gegenstand weiterführender Forschung, was zur Entwicklung eines vielversprechenden Prototyps namens cHash geführt hat. Um mehr darüber zu erfahren, lesen Sie hier weiter.

Anmerkungen

  1. Und wie wir alle wissen, sind Kontextwechsel zwischen mentalen Modellen meist sehr teuer.
  2. Ich kann mir einen Kollisionsangriff vorstellen, bei dem ein böswilliger Akteur Ihren Kompilierungscache mit einer gefälschten Quelldatei vergiftet, die zufällig denselben Tag erzeugt wie eine Ihrer Projektdateien. Wenn das zwischengespeicherte Artefakt erfolgreich in Ihr Binärprogramm gelinkt wird (wobei es auch die gleichen Symbole bereitstellen muss), dann konnte der Angreifer bösartigen Code in Ihr Programm einbringen. Dies ist aber ein sehr weit hergeholtes Szenario.
  3. Sowohl Ihr Buildverzeichnis als auch der Cache sollten auf einer schnellen SSD liegen. Wenn Ihr Computer noch keine SSD hat, installieren Sie eine!
  4. Und falls Sie Ninja noch nicht kennen: Es ist ein Buildsystem, das besonders für seine Schnelligkeit bekannt ist. Ninja geht davon aus, dass die Kontrolldateien nicht handgeschrieben, sondern generiert sind.

Mehr zu dem Thema

Softwareengineering-Services

 

UL Solutions bietet Unternehmen aus verschiedenen Branchen umfassende Dienstleistungen an. Dazu gehören Zertifizierungen, Tests, Inspektionen, Assessments, Verifizierungen und Beratungsdienste. Um Interessenkonflikte zu verhindern, zu erkennen und zu vermeiden und um unsere Marke und die Marken unserer Kunden zu schützen, hat UL Solutions Verfahren zur Erkennung und Handhabung potenzieller Interessenkonflikte eingeführt. Damit wollen wir sicherstellen, dass unsere Konformitätsassessments objektiv bleiben.