Laufzeitgewinn messen
Schnellere .NET-CI-Pipelines mit Test Sharding, Teil 4
Der vorangegangene Teil 3 dieser Serie zum Thema „Schnellere .NET-CI-Pipelines mit Test Sharding“ hat die GitLab-Pipeline samt JUnit-Anzeige und getrennten Testdaten eingerichtet. Damit steht die technische Ausführung. Nun folgt die Entscheidung über die Shard-Anzahl: Wie viel früher liegt das vollständige Ergebnis vor, und welchen Ressourcenverbrauch verursacht die Beschleunigung?
Feedbackzeit und Runner-Minuten trennen
Die Feedbackzeit reicht vom Pipeline-Trigger bis zum vollständigen relevanten Ergebnis. Runner-Minuten summieren dagegen die Ausführungszeiten aller Jobs, ohne Warteschlangenzeit. Vier gleichzeitige Zehn-Minuten-Jobs belegen vierzig Runner-Minuten, das Ergebnis dieser Stufe liegt aber nach etwa zehn Minuten vor. Die Abrechnung kann davon abweichen, etwa durch Rundung, Freikontingente oder von Runnern unabhängige Kostenfaktoren.
Den kritischen Pfad erfassen
Die Teststufe endet mit dem zuletzt fertigen Shard, einschließlich Warteschlange, Vorbereitung, Discovery, Tests und Reports. Ein gemeinsamer Build davor und eine Ergebnisprüfung danach verlängern den kritischen Pfad: die Abfolge abhängiger Schritte, welche die Gesamtdauer bestimmt. Knappe Runner-Kapazität erzeugt weitere Wartezeiten durch mehrere Jobwellen.
Dauert der Build vier Minuten und der Testlauf zwanzig, benötigen selbst vier ideale Shards mindestens vier plus fünf Minuten – noch ohne Zusatzaufwand wie die Discovery, die je Shard erneut anfällt. Ein zehnminütiger Test setzt zudem eine Untergrenze für seinen Shard: Das Verfahren zerlegt dessen Testkörper nicht.
Rechenintensive Tests können von zusätzlichen CPU-Kernen auf weiteren Runnern profitieren. Bei I/O-Wartezeiten lohnt zuerst die interne Parallelisierung: Sie überlappt Wartephasen ohne zusätzliche Jobs. Sharding kann auch hier helfen, sofern Datenbank oder API die zusätzliche Last bewältigen.
Zwei Referenzen statt eines Vorherwerts
Zunächst läuft dieselbe Suite mit dem bisherigen dotnet test-Aufruf. Danach folgt ShardedTest mit genau einem Shard. Dieser zweite Referenzlauf zeigt, welchen Zusatzaufwand Discovery und Filterung verursachen. Anschließend vergleichen wir zwei, vier und gegebenenfalls acht Shards. Die interne Framework-Parallelisierung bleibt zunächst konstant.
Commit, Testdaten, SDK-Patch, Paketversionen und Runner-Klasse bleiben gleich. Auch Coverage, also die Erfassung der Codeabdeckung, muss in allen Varianten gleich konfiguriert sein. Warme Caches mit bereits geladenen Paketen und kalte Caches bilden getrennte Messgruppen. Pro Job notieren wir Wartezeit, Vorbereitung, Dauer des ShardedTest-Aufrufs, Reportverarbeitung und Gesamtdauer. Detaillierte Logs trennen innerhalb des Aufrufs Discovery und Testausführung.
Für einen ersten Vergleich eignen sich zehn bis zwanzig Wiederholungen pro Variante in wechselnder Reihenfolge. Das begrenzt Einflüsse durch Tageszeit und Infrastruktur. Das Protokoll enthält Median, Streuung und Ausreißer. Das 95. Perzentil (p95), unter dem 95 Prozent der Laufzeiten liegen, bleibt bei so wenigen Läufen instabil: Seltene Verzögerungen brauchen mehr Beobachtungen.
Ein Rechenbeispiel richtig lesen
Die Zahlen der nachstehenden Tabelle kommen aus einem Projekt. Die Teststufe enthält Vorbereitung und Reports. Runner-Minuten summieren die Jobdauern; der langsamste Job mal Shard-Anzahl wäre hierfür falsch.
Messungen verschiedener Testläufe mit einer variierenden Shard-Anzahl
| Variante | Teststufe in Minuten | Runner-Minuten |
|---|---|---|
| Bisheriger Lauf | 26,0 | 26,0 |
| Ein Shard | 26,5 | 26,5 |
| Zwei Shards | 15,0 | 29,0 |
| Vier Shards | 10,0 | 34,0 |
| Acht Shards | 8,0 | 49,0 |
Gegenüber dem bisherigen Lauf verkürzen vier Shards die Teststufe hier um rund 62 Prozent: (26 – 10) / 26. Die Runner-Zeit steigt um rund 31 Prozent: (34 − 26) / 26. Acht Shards sparen gegenüber vier Shards nur zwei weitere Minuten ein, benötigen aber 15 zusätzliche Runner-Minuten. Ob sich das lohnt, hängt von der Häufigkeit der Pipeline und dem Wert des früheren Feedbacks ab.
Die Kostenrechnung verwendet abgerechnete Minuten, Kostenfaktoren und Speicherpreise. Eigene Runner verursachen auch Betriebs- und Leerlaufkosten. Eingesparte Wartezeit ist nicht automatisch auch eingesparte Arbeitszeit. Relevant ist, ob späte Rückmeldungen Arbeitswechsel, blockierte Reviews oder verzögerte Releases verursachen.
Ein Ungleichgewicht sichtbar machen
Gleich viele Tests pro Shard bedeuten keine gleich langen Jobs. ShardedTest 1.1.0 verteilt die sortierte Liste nach Position modulo Shard-Anzahl. Es liest keine historischen Testdauern ein und verschiebt nach dem Start keine Arbeit von einem langsamen zu einem bereits fertigen Runner.
Ein einfaches Maß ist die längste gemessene Shard-Testdauer geteilt durch den Mittelwert aller Shard-Testdauern; Vorbereitung und Warteschlange bleiben außen vor. Eins beschreibt Gleichstand; zehn Minuten Maximum bei fünf Minuten Mittelwert ergeben den Faktor zwei. Größere Werte lenken die Suche auf lange Tests oder teure Fixtures. Sind wenige Fälle dauerhaft sehr teuer, lohnen sich deren Optimierung oder eine eigene Testgruppe vor weiteren Shards.
Das folgende Python-3-Beispiel verarbeitet die Ergebnisse der Shards:
from collections import defaultdict
from pathlib import Path
import xml.etree.ElementTree as ET
totals = defaultdict(lambda: [0, 0.0])
ns = {"t": "http://microsoft.com/schemas/VisualStudio/TeamTest/2010"}
for path in Path("results").glob("*/*.trx"):
shard = path.parent.name
root = ET.parse(path).getroot()
for test in root.findall(".//t:UnitTestResult", ns):
totals[shard][0] += 1 duration = test.get("duration")
if not duration:
continue
hours, minutes, seconds = duration.split(":") days, _, hours = hours.rpartition(".")
totals[shard][1] += (
int(days or 0) * 86400 + int(hours) * 3600 + int(minutes) * 60 + float(seconds)
)
for shard, (count, seconds) in sorted(totals.items()):
print(shard, count, round(seconds, 2))
Das Python-Script summiert zusätzlich die in TRX gespeicherten Einzelfallzeiten. Dazu liegen ausschließlich Reports eines Vergleichslaufs unter results/<shard>/*.trx, unabhängig vom verwendeten CI-Anbieter. Das Skript gruppiert sie nach Shard. Parallel laufende Tests überlappen sich jedoch: Ihre Zeitsumme ist keine Jobdauer und ersetzt nicht das vorherige Ungleichgewichtsmaß.
defaultdict verwaltet Zähler und Zeitsumme je Shard. glob findet die TRX-Dateien; path.parent.name liefert die Shard-Nummer. ns benennt den XML-Namensraum für die Suche nach UnitTestResult. Das Skript zählt diese Einträge und rechnet vorhandene duration-Werte in Sekunden um, einschließlich eines optionalen Tagesanteils. Es gibt Shard-Nummer, Ergebniszahl und Zeitsumme aus. Zwei parallele Zehn-Sekunden-Tests liefern zwanzig Fallsekunden bei etwa zehn Sekunden verstrichener Zeit.
Vollständigkeit als eigene Prüfung
Vor dem produktiven Umschalten vergleichen wir Auswahl und Ausführung mit einem Referenzlauf ohne Sharding. Dafür dient ein fehlerfrei abgeschlossener Lauf desselben Commits mit identischer Testkonfiguration. Zusammen müssen die Shards alle erwarteten Fälle enthalten; kein Fall darf zwei Shards angehören. Als Identität zählen Projekt, Ziel-Framework und Testfall einschließlich Datensatz. Gesamtzahlen reichen nicht: Ein fehlender und ein doppelter Test können sich numerisch ausgleichen. Übersprungene Tests erfassen wir getrennt von ausgeführten Tests.
Besondere Aufmerksamkeit verdienen Anzeigenamen. Das Tool bildet seine Filter aus DisplayName, nicht aus einer global eindeutigen, um Projekt und Ziel-Framework erweiterten Identität. Bei gleichen Anzeigenamen in mehreren Modulen lässt sich aus einem Namensfilter keine eindeutige Zuordnung ableiten. Deshalb beginnen die Beispiele bewusst mit einem einzelnen Projekt und einem Ziel-Framework.
Auch Exitcode null allein genügt nicht. Ein leer ausgewählter Shard endet in Version 1.1.0 erfolgreich. Bei mehreren MTP-Modulen ergänzt das Tool gegebenenfalls --ignore-exit-code 8, damit ein Modul ohne passende Tests den Shard nicht scheitern lässt. Die Discovery akzeptiert zudem einen Fehlercode, sofern sie bereits Tests parsen konnte. Deshalb prüfen wir Sollbestand, Auswahl und Berichte getrennt. Das Python-Listing liefert nur eine erste Übersicht, keinen Identitätsvergleich.
Die Shard-Anzahl folgt dem Messergebnis
Im Betrieb gehören fehlende Reports und sporadische Testfehler neben der Laufzeit ins Monitoring. Wiederholte Läufe dürfen den ursprünglichen Fehlschlag nicht verdecken. Neue Fehler nach der Aufteilung können auf geteilten Zustand oder überlastete Dienste hinweisen.
Zwei oder vier Shards mit geprüftem Testumfang bilden einen sinnvollen Einstieg. Weitere Jobs lohnen sich, solange frühere Rückmeldungen ihren zusätzlichen Ressourcenverbrauch rechtfertigen. Stagniert der Gewinn, geraten langsame Tests, Fixtures und Discovery in den Fokus. Mit wachsender Suite braucht es eine neue Messung.