„C++ soll schnell sein.“ „Go ist im Betrieb leichtgewichtig.“ „Java wird bei langer Laufzeit richtig schnell.“ „C# ist dank des .NET-JIT überraschend stark.“
Solche Aussagen hört man häufig. Das Schlimmste, was man dabei tun kann, ist jedoch, Zahlen, die verschiedene Personen in verschiedenen Umgebungen gemessen haben, nebeneinanderzulegen und daraus unmittelbar eine Rangfolge der Sprachen abzuleiten.
C# und Java reagieren empfindlich auf JIT und Warm-up, während C++ und Go normalerweise bereits vorab kompiliert sind. Auch Vorhandensein und Eigenschaften der GC unterscheiden sich. Unterschiede in der Implementierung von Standardbibliothek und Zusatzbibliotheken wirken sich ebenfalls stark aus. Und selbst auf derselben Maschine schwanken die Ergebnisse leicht durch Energieeinstellungen, Wärme, Hintergrundprozesse und Verzerrungen in den Eingabedaten. Es ist eine ziemlich mühsame Welt.
Dieser Artikel ordnet, wie man C# / C++ / Java / Go möglichst fair vergleicht. Vorab die Schlussfolgerung: Am wichtigsten ist es, nicht zu versuchen, „welche Sprache am schnellsten ist“ mit einer einzigen Zahl zu entscheiden.
Dieser Artikel widmet sich ausschließlich der Systematik der Vergleichsmethode. Umgebungsabhängige Zahlen aneinanderzureihen führt bei anderen Bedingungen leicht zu einer Umkehrung. Deshalb wird hier keine gemessene Rangliste veröffentlicht. Stattdessen konzentrieren wir uns darauf, wie man den Vergleich so gestaltet, dass er wirklich einen Wert hat.
Zielgruppe
Dieser Artikel richtet sich an Entwickler und technische Leiter, die zwischen mehreren Sprachkandidaten wählen und die Implementierungsentscheidung anhand der Leistung treffen wollen, sowie an alle, die intern oder in einem Bericht eine Messung aufbauen möchten, mit der sich seriös sagen lässt „wir haben die Geschwindigkeit verglichen“. Im Mittelpunkt steht nicht die Vertiefung einer einzelnen Sprache, sondern das Design eines Vergleichs über vier Sprachen hinweg, sodass der Inhalt auch für Leser verständlich ist, die nur mit einer der vier Sprachen arbeiten.
Die Codebeispiele verwenden PowerShell als gemeinsamen Runner sowie BenchmarkDotNet für C# und JMH für Java als sprachinterne Harness. Für C++ und Go werden nur die abzugleichenden Bedingungen aufgeführt.
Begriffe, die man vorab kennen sollte
Im Text tauchen einige Begriffe im englischen Original auf. Damit Sie beim ersten Vorkommen nicht stolpern, sind sie hier vorab zusammengefasst.
| Begriff | Bedeutung |
|---|---|
| p95 / p99 | Perzentil. Ordnet man alle Läufe nach Geschwindigkeit, ist dies der Wert an der 95./99. Position von unten. „Bei 5 von 100 Läufen ist es langsamer als dieser Wert“ entspricht p95 |
| RSS (Resident Set Size) | Die Größe, die ein Prozess tatsächlich im physischen Speicher belegt. Nicht die reservierte virtuelle Speichermenge, sondern „wie viel realer Speicher gerade belegt ist“ |
| LTO (Link Time Optimization) | Ein Mechanismus, der beim Linken über Übersetzungseinheiten hinweg optimiert. Entspricht -flto bei GCC/Clang bzw. /GL und /LTCG bei MSVC |
| PGO (Profile-Guided Optimization) | Ein Mechanismus, der ein aus einem Testlauf gesammeltes Profil von Verzweigungen und Aufrufen für die Optimierungsentscheidungen des nächsten Builds nutzt |
| Tiered Compilation | Ein Mechanismus, bei dem der .NET-JIT zunächst mit schnell kompilierbarem Code läuft und nur häufig aufgerufene Methoden später neu optimiert. Einer der Hauptgründe für den Unterschied zwischen cold und warm |
| Server GC / Workstation GC | Betriebsmodi der .NET-GC. Server GC besitzt pro logischem Prozessor einen eigenen Heap und eigene GC-Threads und ist auf Durchsatz ausgelegt, Workstation GC eher auf Reaktionsfähigkeit |
| GOMAXPROCS | Die Obergrenze der OS-Threads, mit denen die Go-Laufzeitumgebung Go-Code gleichzeitig ausführt. In parallelen Benchmarks lassen sich Ergebnisse nur vergleichen, wenn dieser Wert fixiert ist |
| cgo | Der Mechanismus, mit dem Go C-Code aufruft. Ist er aktiviert, ändern sich Aufrufkosten, Build-Bedingungen und ob statisches Linken möglich ist |
Das Fazit vorab
Bei einem Geschwindigkeitsvergleich von C# / C++ / Java / Go zählen wirklich diese sieben Punkte.
-
Zuerst festlegen, welche Art von Geschwindigkeit verglichen werden soll Ob es um Startzeit, Steady-State-Durchsatz, p95-Latenz oder Speichereffizienz geht, ändert die Messmethode.
-
Nie aus einem einzigen Benchmark ein Fazit ziehen Bei CPU-Berechnung, Speicherzuweisung, paralleler Verarbeitung und Startzeit erscheinen jeweils andere Sprachen und Laufzeitumgebungen stark.
-
Bei C# und Java cold und warm trennen Vermischt man einen Vergleich, der den ersten Lauf einschließt, mit einem Steady-State-Vergleich nach dem Warm-up, verdreht sich die ganze Diskussion.
-
Mit demselben Algorithmus, derselben Eingabe und derselben Korrektheitsprüfung messen „Es war keine schnellere Implementierung, sie hat nur ein anderes Problem gelöst“ ist ein klassischer Benchmark-Fehler.
-
Sprachinterne Microbenchmarks von sprachübergreifenden End-to-End-Benchmarks trennen Die spezielle Harness jeder Sprache ist praktisch, aber ein sprachübergreifender Vergleich läuft sauberer über einen äußeren gemeinsamen Runner.
-
Nicht nur den Mittelwert, sondern auch Median und Verteilung betrachten Schon eine einzige GC-Pause oder ein Hintergrundprozess in nur einem Lauf reicht, um den Mittelwert zu verfälschen.
-
Nicht nur die Zahlen, sondern auch die Bedingungen dokumentieren Ein Benchmark-Ergebnis ist ebenso ein Protokoll der Geschwindigkeit wie ein Protokoll der Versuchsbedingungen. Ergebnisse ohne dokumentierte Bedingungen werden später ziemlich mühsam.
Was man zuerst entscheiden sollte
Lässt man „schnell“ ein einzelnes, unpräzises Wort bleiben, geht es meistens schief. Zuerst legt man fest, was man als schnell bezeichnet.
Selbst bei demselben Programm kann das, was man sehen will, ziemlich unterschiedlich sein.
1. Wollen Sie die Startzeit betrachten?
Bei CLI-Tools, kurzlebigen Batch-Jobs und Hilfswerkzeugen, die einmal starten und sofort enden, zählen cold start und process startup. Auf dieser Achse ändern sich die Ergebnisse stark, je nachdem, ob Initialisierungskosten für JIT und Class Loading eingerechnet werden.
2. Wollen Sie den Durchsatz im Dauerbetrieb betrachten?
Bei Servern, dauerhaft laufenden Prozessen, Workern und lange laufenden Konvertierungsprozessen zählt der Steady-State-Durchsatz. In diesem Fall ist es nicht das Wesentliche, dass nur der erste Lauf langsam ist; entscheidend ist, wie stabil und wie hoch der Durchsatz nach dem Warm-up steigt.
3. Wollen Sie die Tail Latency betrachten?
Bei APIs, UI und eher echtzeitnahen Prozessen können p95 / p99 wichtiger sein als der Mittelwert. Auch wenn der Durchschnitt schnell ist, sind gelegentliche lange Stockungen aus Sicht von Nutzererfahrung und SLA schmerzhaft.
4. Wollen Sie auch die Speichereffizienz einbeziehen?
Betrachtet man nur die CPU-Zeit und nicht maximale RSS, Zuweisungsmenge, Anzahl der GC-Läufe und GC-Pausen, verschätzt man sich beim tatsächlichen Betriebsgewicht. „Schnell, verbraucht aber viel Speicher“ und „etwas langsamer, aber stabil leichtgewichtig“ können sich je nach Einsatzzweck in der Bewertung umkehren.
Zusammengefasst lautet die zuerst zu klärende Frage:
Was dieser Vergleich beantworten soll, ist nicht, welche Sprache schnell ist, sondern welchen Workload man unter welchen Bedingungen mit welcher Kennzahl schneller verarbeiten kann.
Bleibt das vage, während man Zahlen sammelt, fügt sich am Ende nichts zusammen.
Warum der Sprachenvergleich schwierig ist
Das Mischen von JIT und AOT macht daraus ein anderes Experiment
C# und Java unterliegen normalerweise dem Einfluss von JIT. C++ und Go hingegen sind normalerweise bereits vorab kompiliert.
Das bedeutet: Misst man den ersten Lauf, misst man nicht nur die Geschwindigkeit des Programms selbst, sondern auch den Start der Laufzeitumgebung, das Class Loading und die JIT-Vorbereitung mit. Betrachtet man umgekehrt nur vollständig aufgewärmte Läufe, wird daraus ein Vergleich, wie weit die Optimierung im stabilen Zustand reicht.
Beides ist aussagekräftig. Aber es bedeutet nicht dasselbe.
Implementierungsunterschiede sind häufig größer als Sprachunterschiede
Selbst bei demselben „Sortieren“
- verwendet die eine Seite die Standardbibliothek
- ist die andere Seite selbst geschrieben
- macht die eine Seite überflüssige Kopien
- erzeugt die andere Seite die Eingabe jedes Mal neu
Allein das verändert die Ergebnisse erheblich.
Geht es dann noch um JSON, Kompression, Kryptografie oder reguläre Ausdrücke, wirken sich Unterschiede in der Bibliotheksimplementierung deutlich stärker aus als die Sprache selbst. Macht man daher nicht explizit, was genau gemessen wird, wird aus einem beabsichtigten „Sprachvergleich“ ein „Bibliotheksvergleich“.
C++ hat die Falle, dass Optimierung die Arbeit verschwinden lässt
Besonders bei Microbenchmarks kann der Compiler, wenn er entscheidet „dieses Berechnungsergebnis nutzt niemand“, die Verarbeitung selbst entfernen. Dann misst man nicht Schnelligkeit, sondern das Ergebnis, dass überhaupt nichts getan wird. Ein typisches Bild dafür ist, dass die ganze Schleife verschwindet und die Ausführungszeit nahezu null wird.
Da dieses Problem in C++ besonders deutlich auftritt, sind die Verwendung des Ergebnisses, die Ausgabe eines checksums und die Optimierungsunterdrückungsfunktionen des Benchmark-Frameworks ziemlich wichtig.
Das Vorhandensein einer GC ist weder „Nachteil“ noch „Vorteil“ — sondern eine Eigenschaft
C#, Java und Go besitzen eine GC. Das schlicht auf „hat GC, also langsam“ zu reduzieren, ist viel zu grob.
Tatsächlich zählt eher
- wie große Mengen kurzlebiger Objekte gehandhabt werden
- die Konfiguration der Heap-Größe
- Häufigkeit und Pausen der GC
- das Objektlayout
- die Zuweisungsgewohnheiten der Bibliotheken
Umgekehrt lässt sich C++ durch manuelle Verwaltung und RAII fein steuern, wodurch sich aber Unterschiede in Design und Implementierung leichter bemerkbar machen. Mit anderen Worten: Ein Unterschied in der Verwaltungsstrategie ist für sich genommen kein Urteil über gut oder schlecht.
Was man beim Vergleich nicht tun sollte
1. Debug- und Release-Builds mischen
Das kommt nicht infrage. Die Vergleichsobjekte müssen immer auf produktionsnahe optimierte Builds abgeglichen werden.
2. Nicht dasselbe Problem lösen
Unterschiedliche Eingabeformate, unterschiedliche Ausgaben, Fehlerbehandlung nur auf einer Seite, unterschiedliche Richtlinien zur Speicherwiederverwendung. Lässt man das unbeachtet, misst man Anforderungsunterschiede statt Geschwindigkeit.
3. Nach nur einem Lauf ein Fazit ziehen
Ein einzelner Lauf ist meist nur Rauschen.
- JIT
- Page-Cache
- CPU-Boost
- Wärme
- Hintergrundprozesse
- GC
- Erstes Einlesen von Dateien
All das mischt sich in einem einzigen Lauf.
4. Warm-up vermischen
Bleibt bei der Messung von C# und Java unklar, ob der erste Lauf eingeschlossen ist oder nur die Läufe nach dem Warm-up zählen, bricht die Diskussion zusammen. Behandeln Sie cold und warm als getrennte Dinge.
5. Keine Korrektheitsprüfung durchführen
Ein Benchmark braucht vor „schnell“ zuerst „liefert dasselbe Ergebnis“. Prüfen Sie bei allen verglichenen Implementierungen unbedingt, dass aus derselben Eingabe derselbe checksum bzw. dieselbe Ausgabe entsteht.
6. Sich das Weltbild anhand eines einzigen Microbenchmarks bilden
Ein Sieg in einer tight loop bedeutet nicht zwingend einen Sieg im gesamten realen Dienst. Umgekehrt kann eine Niederlage bei der Startzeit im Dauerbetrieb durchaus noch ausreichend stark sein.
Die Grundhaltung beim Vergleich von C# / C++ / Java / Go
Dieser Punkt ist ziemlich wichtig. Empfohlen wird eine zweischichtige Struktur.
flowchart TB
subgraph outer["Äußere Schicht: sprachübergreifender gemeinsamer Runner"]
direction TB
R["Gemeinsamer Runner<br/>Randomisierung der Ausführungsreihenfolge / Trennung von cold und warm<br/>checksum-Prüfung / Speichern der Rohdaten"]
R --> E1["bench-Ausführungsdatei<br/>C++"]
R --> E2["bench-Ausführungsdatei<br/>C#"]
R --> E3["bench-Ausführungsdatei<br/>Java"]
R --> E4["bench-Ausführungsdatei<br/>Go"]
end
subgraph inner["Innere Schicht: sprachinterne Vertiefung"]
direction TB
H1["Google Benchmark"]
H2["BenchmarkDotNet"]
H3["JMH"]
H4["go test -bench und benchstat"]
end
E1 -.->|"Denselben Vorgang sprachintern genauer messen"| H1
E2 -.->|"Denselben Vorgang sprachintern genauer messen"| H2
E3 -.->|"Denselben Vorgang sprachintern genauer messen"| H3
E4 -.->|"Denselben Vorgang sprachintern genauer messen"| H4
Die Zahlen aus der äußeren Schicht sind die Zahlen, die man sprachübergreifend vergleichen darf, die Zahlen aus der inneren Schicht sind die Zahlen, mit denen man Verbesserungen innerhalb der jeweiligen Sprache verfolgt. Diese beiden nicht in einer einzigen Tabelle zu vermischen, ist der entscheidende Punkt.
1. Für sprachinterne Messungen die zur jeweiligen Sprache passende Harness verwenden
Jede Sprache verfügt über Benchmark-Werkzeuge, die die Eigenheiten dieser Sprache abfedern.
- C#: BenchmarkDotNet
- Java: JMH
- Go:
go test -benchundbenchstat - C++: Google Benchmark
Diese kümmern sich in gewissem Umfang um die Eigenheiten der jeweiligen Laufzeitumgebung, um die statistische Auswertung und um typische Messfallen. Für den Vergleich innerhalb einer Sprache und für das Vertiefen einer Implementierung sind sie ziemlich wirksam.
Misst man etwa „das Sortieren von 10 Millionen int32-Werten“ mit BenchmarkDotNet, sieht die minimale Konfiguration so aus.
// C#/.NET 8 + BenchmarkDotNet 0.13.x
// dotnet add package BenchmarkDotNet
// dotnet run -c Release
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkRunner.Run<SortBench>();
[MemoryDiagnoser] // Gibt zusätzlich Allokationsmenge und GC-Anzahl aus
public class SortBench
{
private int[] _source = Array.Empty<int>();
private int[] _work = Array.Empty<int>();
[GlobalSetup]
public void Setup()
{
var rng = new Random(12345); // Seed fest wählen, damit die Eingabe jedes Mal gleich ist
_source = new int[10_000_000];
for (int i = 0; i < _source.Length; i++)
{
_source[i] = rng.Next();
}
_work = new int[_source.Length];
}
[IterationSetup] // Setzt vor jedem Durchlauf den unsortierten Zustand zurück
public void ResetInput() => Array.Copy(_source, _work, _source.Length);
[Benchmark]
public long SortInt32()
{
Array.Sort(_work);
long checksum = 0;
foreach (int v in _work)
{
checksum = checksum * 31 + v;
}
return checksum; // Ergebnis zurückgeben, damit es nicht wegoptimiert wird
}
}
[IterationSetup] unterliegt einer Einschränkung. Die offizielle Dokumentation von BenchmarkDotNet rät bei Microbenchmarks davon ab, weil es die Ergebnisse verfälscht, hält es aber bei Makrobenchmarks ab etwa 100 ms Laufzeit für sinnvoll. Das Sortieren von 10 Millionen Elementen erfüllt diese Bedingung, aber bei kurzen Verarbeitungen sollten Sie stattdessen auf die Seite von [GlobalSetup] wechseln, um den Zustand mitzuführen.
Bei Javas JMH ist die Überlegung dieselbe.
// Java / JMH. In der pom.xml jmh-core und jmh-generator-annprocess eintragen
// mvn clean verify
// java -jar target/benchmarks.jar SortBench
package bench;
import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.annotations.BenchmarkMode;
import org.openjdk.jmh.annotations.Fork;
import org.openjdk.jmh.annotations.Level;
import org.openjdk.jmh.annotations.Measurement;
import org.openjdk.jmh.annotations.Mode;
import org.openjdk.jmh.annotations.OutputTimeUnit;
import org.openjdk.jmh.annotations.Scope;
import org.openjdk.jmh.annotations.Setup;
import org.openjdk.jmh.annotations.State;
import org.openjdk.jmh.annotations.Warmup;
import java.util.Arrays;
import java.util.Random;
import java.util.concurrent.TimeUnit;
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@State(Scope.Benchmark)
@Warmup(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
@Measurement(iterations = 10, time = 1, timeUnit = TimeUnit.SECONDS)
@Fork(3) // Separate JVMs, um JIT-Zufälligkeiten auszugleichen
public class SortBench {
private int[] source;
private int[] work;
@Setup(Level.Trial)
public void setUp() {
Random rng = new Random(12345); // Seed fest wählen, damit die Eingabe jedes Mal gleich ist
source = new int[10_000_000];
for (int i = 0; i < source.length; i++) {
source[i] = rng.nextInt();
}
work = new int[source.length];
}
@Setup(Level.Invocation) // Setzt vor jedem Aufruf den unsortierten Zustand zurück
public void resetInput() {
System.arraycopy(source, 0, work, 0, source.length);
}
@Benchmark
public long sortInt32() {
Arrays.sort(work);
long checksum = 0;
for (int v : work) {
checksum = checksum * 31 + v;
}
return checksum; // Der Rückgabewert wird von JMH konsumiert, daher wird er nicht wegoptimiert
}
}
Level.Invocation unterliegt einer Einschränkung derselben Art. Das Javadoc von JMH stellt ausdrücklich klar, dass diese Stufe nur für Benchmarks sinnvoll ist, bei denen ein einzelner Aufruf der @Benchmark-Methode mehr als eine Millisekunde dauert. Da bei jedem Aufruf ein Zeitstempel genommen wird, wird bei kurzen Verarbeitungen die Messung selbst zum Engpass.
Die Werte von @Warmup / @Measurement / @Fork bei BenchmarkDotNet und JMH sind unmittelbar Versuchsbedingungen. Bewahren Sie sie unbedingt zusammen mit den Ergebnissen auf.
2. Für sprachübergreifende Vergleiche einen gemeinsamen Runner außen platzieren
Andererseits ist es etwas riskant, das Ergebnis von BenchmarkDotNet für C# und das Ergebnis von JMH für Java einfach nebeneinanderzulegen. Die Konventionen der Harnesses selbst unterscheiden sich.
Für den sprachübergreifenden Vergleich empfiehlt es sich daher, jede Implementierung zu einer ausführbaren Datei mit demselben CLI-Vertrag zu machen und sie von außen unter identischen Bedingungen laufen zu lassen.
Bereiten Sie zum Beispiel in jeder Sprache eine ausführbare Datei dieser Form vor.
bench --scenario sort_int32 --dataset data/sort_10m.bin --mode warm
bench --scenario group_words --dataset data/words_100mb.txt --mode cold
bench --scenario parallel_hash --dataset data/blob_1gb.bin --threads 8
Auch die Ausgabeseite wird zum Vertrag. Vereinbart man, dass jede sprachliche Implementierung ausschließlich diese zwei Zeilen auf die Standardausgabe schreibt, kommt die Auswertung auf der Runner-Seite mit einem einzigen regulären Ausdruck aus.
checksum=[Hexadezimalzeichenkette]
inner_ms=[Millisekunden als Dezimalzahl]
checksum dient der Korrektheitsprüfung, inner_ms ist die vom Implementierungscode selbst gemessene Zeit der Kernverarbeitung. Da der Runner den Wall-Clock-Wert des gesamten Prozesses separat misst, bleiben sowohl die Zeit inklusive Start als auch die reine Zeit der Kernverarbeitung erhalten.
Und auf der Seite des gemeinsamen Runners:
- die Ausführungsreihenfolge randomisieren
- cold / warm trennen
- denselben Datensatz übergeben
- den checksum prüfen
- Wall-Clock-Zeit und Speicher erfassen
- Rohdaten in CSV/JSON aufbewahren
Nur das Grundgerüst geschrieben, sieht das so aus.
# run-bench.ps1 : gemeinsamer sprachübergreifender Runner (Grundgerüst)
# Läuft mit PowerShell 7.4.
# pwsh ./run-bench.ps1 -Scenario sort_int32 -Dataset ./data/sort_10m.bin -Runs 15
param(
[Parameter(Mandatory = $true)][string]$Scenario,
[Parameter(Mandatory = $true)][string]$Dataset,
[int]$Runs = 15,
[int]$WarmupRuns = 3,
[string]$OutCsv = "./results/raw.csv"
)
# Die ausführbaren Dateien der einzelnen Sprachen. Der CLI-Vertrag ist bei allen 4 Implementierungen exakt gleich.
# Für Java wird nur ein dünner Wrapper um java -jar gelegt, damit der Aufruf einheitlich ist.
$Implementations = @(
@{ Language = "cpp"; Exe = "./build/cpp/bench.exe" },
@{ Language = "csharp"; Exe = "./build/csharp/bench.exe" },
@{ Language = "java"; Exe = "./build/java/bench.cmd" },
@{ Language = "go"; Exe = "./build/go/bench.exe" }
)
function Invoke-OneRun {
param(
[Parameter(Mandatory = $true)][hashtable]$Impl,
[Parameter(Mandatory = $true)][string]$Mode,
[Parameter(Mandatory = $true)][int]$Index
)
# $Scenario und $Dataset sind die im param-Block oben definierten Werte
$sw = [System.Diagnostics.Stopwatch]::StartNew()
$stdout = & $Impl.Exe --scenario $Scenario --dataset $Dataset --mode $Mode
$sw.Stop()
$exitCode = $LASTEXITCODE
# Der Ausgabevertrag mit der Implementierung wird hier an genau einer Stelle geparst
$checksum = ""
$innerMs = [double]::NaN
foreach ($line in $stdout) {
if ($line -match '^checksum=(\S+)$') { $checksum = $Matches[1] }
if ($line -match '^inner_ms=(\S+)$') { $innerMs = [double]$Matches[1] }
}
if ($exitCode -ne 0 -or [string]::IsNullOrEmpty($checksum)) {
throw "$($Impl.Language) / $Mode / Lauf $Index ist fehlgeschlagen. exit=$exitCode"
}
# Implementierungen, die vergessen haben, inner_ms auszugeben, liefern mit Exit-Code 0 nur den checksum zurück.
# Wird das hier nicht abgefangen, landet NaN im CSV, und der Vergleich der inneren Zeiten bricht stillschweigend
if ([double]::IsNaN($innerMs) -or [double]::IsInfinity($innerMs) -or $innerMs -lt 0) {
throw "$($Impl.Language) / $Mode / Lauf $Index liefert kein inner_ms (Wert: $innerMs)." +
"Der Ausgabevertrag besteht aus den zwei Zeilen checksum= und inner_ms="
}
return [pscustomobject]@{
timestamp = (Get-Date).ToString("o")
language = $Impl.Language
scenario = $Scenario
cold_or_warm = $Mode
run_index = $Index
process_ms = [math]::Round($sw.Elapsed.TotalMilliseconds, 3)
inner_ms = $innerMs
checksum = $checksum
}
}
# 1. Zuerst die Korrektheitsprüfung. Liefern nicht alle Implementierungen denselben checksum,
# hat das Messen der Geschwindigkeit keinen Sinn
$expected = $null
foreach ($impl in $Implementations) {
for ($i = 1; $i -le $WarmupRuns; $i++) {
$r = Invoke-OneRun -Impl $impl -Mode "warm" -Index $i
if ($null -eq $expected) {
$expected = $r.checksum
}
elseif ($r.checksum -ne $expected) {
throw "checksum stimmt nicht überein. $($impl.Language) liefert $($r.checksum), Referenz ist $expected"
}
}
}
# 2. Produktivlauf. Die Reihenfolge wird pro Lauf gemischt, um Verzerrungen durch Wärme
# und Tageszeit auszugleichen. Da sich Punkt 1 nur auf die verworfenen Warm-up-Läufe bezieht,
# wird auch bei den aufgezeichneten Läufen der checksum bei jedem einzelnen Lauf geprüft.
# Wird das ausgelassen, landen im CSV die Zeiten einer Implementierung, die unterwegs
# angefangen hat, etwas anderes zu tun, und der ganze Vergleich wird ungültig
$rows = [System.Collections.Generic.List[object]]::new()
foreach ($mode in @("cold", "warm")) {
for ($i = 1; $i -le $Runs; $i++) {
$shuffled = $Implementations | Get-Random -Count $Implementations.Count
foreach ($impl in $shuffled) {
$r = Invoke-OneRun -Impl $impl -Mode $mode -Index $i
if ($r.checksum -ne $expected) {
throw "checksum stimmt nicht überein. $($impl.Language) $mode Lauf #$i liefert $($r.checksum), Referenz ist $expected"
}
$rows.Add($r)
}
}
}
# 3. Rohdaten unbedingt aufbewahren. Aggregationen werden später aus diesem CSV berechnet
# Wird ein Name ohne übergeordnetes Verzeichnis wie -OutCsv raw.csv übergeben,
# liefert Split-Path -Parent eine leere Zeichenfolge, und New-Item lehnt das ab.
# Da der Absturz erst nach Abschluss aller Läufe erfolgt, gehen dabei sämtliche Messergebnisse verloren
$outDir = Split-Path -Parent $OutCsv
if ($outDir) { New-Item -ItemType Directory -Force -Path $outDir | Out-Null }
$rows | Export-Csv -Path $OutCsv -NoTypeInformation -Encoding utf8
Write-Host "Rohdaten: $OutCsv / $($rows.Count) Zeilen"
Drei Dinge werden hier absichtlich so gemacht.
- Die Korrektheitsprüfung wird vor die Geschwindigkeitsmessung gestellt. Es hat keinen Sinn, nur die Geschwindigkeit zu vergleichen, während die checksums nicht übereinstimmen
- Die Reihenfolge wird bei jedem Lauf gemischt. Ziel ist es, nicht erst A komplett durchlaufen zu lassen und dann B
- Es werden nur Rohdaten ausgegeben, ohne zu aggregieren. Mittelwert und Median werden später aus dem CSV berechnet
Tut man das, lassen sich Best Practices innerhalb jeder Sprache und sprachübergreifende Fairness leichter getrennt behandeln.
Konkretes Beispiel: Welche Benchmark-Szenarien sollte man vorbereiten?
Werden Sie gebeten, „C# / C++ / Java / Go zu vergleichen“, empfiehlt sich bei nur einem Durchlauf ein einfaches, schwer misszuverstehendes CPU-Szenario; bei mehreren Durchläufen empfehlen sich 3 bis 4 Workloads mit unterschiedlichem Charakter.
Empfohlene Zusammenstellung
1. sort_int32_10m
Ziel: CPU + Speicherbandbreite + Nutzung von temporärem Speicher betrachten
- Eingabe: 10 Millionen
int32-Werte, erzeugt mit festem Seed - Verarbeitung: Array sortieren und einen checksum zurückgeben
- Zu beachten: Jedes Mal zur selben unsortierten Eingabe zurückkehren
Das ist relativ leicht verständlich. Allerdings schließt es Unterschiede in der Standard-Sortierimplementierung mit ein, sodass es eher ein Vergleich inklusive Standardbibliothek als der Sprache selbst ist.
2. hash_group_count
Ziel: Hashtabellen, Zeichenkettenverarbeitung, Zuweisungen und GC-Tendenzen betrachten
- Eingabe: feste Textdaten
- Verarbeitung: Häufigkeit jedes Wortes zählen
- Ausgabe: die N häufigsten Einträge plus checksum
Das liegt näher an der Praxis, wobei jedoch auch Unterschiede in Zeichenkettenbibliotheken und Map-Implementierungen erheblich ins Gewicht fallen. Dafür ist es ein realitätsnäherer Vergleich.
3. parallel_sha256
Ziel: Parallelverarbeitung, Scheduler, Worker-Pools und Synchronisationsgewohnheiten betrachten
- Eingabe: eine Folge binärer Blöcke fester Größe
- Verarbeitung: mit N Threads hashen und einen finalen checksum zurückgeben
- Bedingung: Thread-Anzahl stufenweise wie 1 / 2 / 4 / 8 variieren
Im Vergleich zu einer einfachen tight loop wird hier das Skalierungsverhalten bei paralleler Ausführung deutlich sichtbarer.
4. startup_noop oder startup_parse_small
Ziel: Startzeit betrachten
noop: startet und beendet sich sofortparse_small: verarbeitet einmalig eine kleine Eingabe und beendet sich
Hier werden JIT und Initialisierungskosten von C# / Java gut sichtbar, und das Bild unterscheidet sich deutlich von C++ / Go. Umgekehrt gilt: Zeigt sich hier ein Unterschied, ist das eine andere Frage als der Sieg bei lange laufender Verarbeitung.
Wie sieht es mit JSON- oder HTTP-Benchmarks aus?
JSON und HTTP liegen nah an der Praxis, daher sind sie natürlich sinnvoll. Allerdings wird es dann eher ein Vergleich inklusive Bibliotheken, Frameworks und Ökosystem als ein Sprachvergleich.
Das ist an sich nicht schlecht. In der Praxis ist sogar oft gerade das die wichtigere Frage. Aber in einem Artikel oder Bericht verursacht es weniger Missverständnisse, ausdrücklich festzuhalten:
Dies ist kein Vergleich der Sprachen, sondern ein Vergleich typischer Implementierungen inklusive der wichtigsten Bibliotheken.
Bedingungen, die pro Sprache abgeglichen werden sollten
C++
- Auf optimierte Builds abgleichen
- Den Compiler fixieren
- Die Implementierung der Standardbibliothek fixieren
- Bedingungen wie
-O3//O2, LTO und PGO dokumentieren - Darauf achten, dass Ergebnisse nicht wegoptimiert werden
- Bei verdächtig guten Ergebnissen undefiniertes Verhalten in Betracht ziehen
C++ bietet viel Freiheit, wodurch sich Bedingungsunterschiede direkt und stark auswirken. Deshalb ist es ziemlich wichtig, mit welchem Compiler, mit welchen Flags und mit welcher STL gemessen wurde.
C#
- Auf Release-Builds abgleichen
- Die .NET-Version fixieren
- Bedingungen wie Server GC / Workstation GC dokumentieren
- Dokumentieren, ob Tiered Compilation, ReadyToRun und Native AOT im Einsatz sind
- Cold und warm trennen
Bei C# verändern Unterschiede in der .NET-Konfiguration das Bild. Insbesondere JIT-kompiliertes C# und Native-AOT-C# sind, obwohl beides „C#“ ist, unterschiedliche Achsen. Mischt man sie, wird nicht mehr die Sprache verglichen, sondern die Verteilungsform.
Java
- JDK-Anbieter und -Version fixieren
- Die GC dokumentieren
- Warm-up / Measurement / Fork fixieren
- Heap-Größe und JVM-Optionen dokumentieren
- Cold start und steady state trennen
Java profitiert leicht vom JIT, doch dafür schwankt das Bild beim ersten Lauf erheblich. Deshalb ist es unerlässlich, den Vergleich kurzlebiger Prozesse von dem Vergleich im Dauerbetrieb zu trennen.
Go
- Die Go-Version fixieren
GOMAXPROCSfixierenCGO_ENABLEDdokumentieren- Wird an
GOGCgedreht, das unbedingt dokumentieren - Nach Möglichkeit die Ausgabe im Benchmark-Format aufbewahren
Go ist vergleichsweise leicht zu handhaben, doch bei parallelen Benchmarks ist der Einfluss von GOMAXPROCS groß. Auch verändert es, ob cgo verwendet wird, das gesamte Bild, sodass dies unbedingt in den Bedingungen festgehalten werden muss.
Wie man die Ausführungsumgebung angleicht
In jeder Sprache gilt: Ein Vergleich ohne angeglichene Umgebung ist meist ein Vergleich von Umgebungen.
Dinge, die abgeglichen werden sollten
- Dieselbe CPU / derselbe Speicher / dieselbe Storage
- Dieselbe Betriebssystemversion
- Dieselben Energiebedingungen
- Bedingungen mit annähernd derselben Raumtemperatur
- Dieselben Eingabedaten
- Dieselbe Prozesspriorität
- Dieselben Kernzahl-Bedingungen
- Dieselben Container- oder Bare-Metal-Bedingungen
Was besonders stark wirkt
Energieeinstellungen und CPU-Taktfrequenz
Bei einem Notebook macht allein Netzbetrieb versus Akkubetrieb eine völlig andere Welt aus. Sind CPU-Governor oder Energiemodus nicht abgeglichen, schwanken die Vergleichsergebnisse erheblich.
Wie man unter Windows Energiebedingungen, Benachrichtigungen, Hintergrundrauschen, Wärme und Ausführungsreihenfolge angleicht, wird im begleitenden Artikel Wie man die Ausführungsgeschwindigkeit verschiedener Programmversionen unter Windows vergleicht im Detail behandelt. Wer unter Windows misst, für den zählt dieser Punkt erheblich.
Wärme
Sind nur die ersten paar Läufe schnell und fällt die Leistung danach ab, liegt der Verdacht auf Wärme und Throttling nahe. Statt A vollständig durchlaufen zu lassen und danach B, verringert ein abwechselndes A / B / A / B die Verzerrung.
Hintergrundprozesse
Updates, Indizierung, Synchronisierung, Virenscans, Browser, Chat-Tools. Das ist unscheinbar, stört aber ganz normal.
Was man messen sollte
Beim Sprachvergleich empfiehlt es sich, mindestens diese vier Dinge getrennt zu betrachten.
1. Wall-Clock-Zeit
Die reale Zeit, die der Nutzer wartet. Das ist die erste Kennzahl, die man betrachten sollte.
2. CPU-Zeit
„Wie viel CPU tatsächlich verbraucht wurde.“ Ist nur die Wall-Clock-Zeit schneller, während die CPU-Zeit gleich bleibt, könnte der Unterschied von Wartezeit oder I/O stammen.
3. Speicher / Zuweisungen
- maximale RSS
- Gesamtzuweisungsmenge
- Anzahl der Zuweisungen
- Anzahl der GC-Läufe
- GC-Pausen
Betrachtet man das, wird die Kosten sichtbar, die hinter der Geschwindigkeit stehen.
4. Verteilung
- Median
- p95 / p99
- min / max
- Standardabweichung und Streuung
Spricht man nur in Mittelwerten, sieht man das wahre Wesen der gelegentlich ausreißenden Läufe nicht.
Empfohlener Ablauf
Der praxistaugliche Ablauf folgt grob dieser Reihenfolge.
flowchart TB
a1["1. Workload festlegen"] --> a2["2. Gemeinsamen Datensatz fixieren"]
a2 --> a3["3. Zuerst die Korrektheitsprüfung bestehen"]
a3 --> a4{"Stimmen die checksums<br/>aller Implementierungen überein?"}
a4 -- Nein --> a3
a4 -- Ja --> a5["4. Build-Bedingungen fixieren"]
a5 --> a6["5. Cold und warm trennen"]
a6 --> a7["6. Mit randomisierter Reihenfolge ausführen"]
a7 --> a8{"Ist die nötige<br/>Anzahl erreicht?"}
a8 -- Nein --> a7
a8 -- Ja --> a9["8. Rohdaten speichern"]
a9 --> a10{"Zeigt sich ein<br/>bedeutsamer Unterschied?"}
a10 -- Nein --> a11["Bedingungen und Anzahl dokumentieren und abschließen"]
a10 -- Ja --> a12["9. Profil erstellen und Ursache untersuchen"]
1. Den Workload festlegen
Machen Sie zuerst explizit, was Sie vergleichen wollen.
- Startzeit
- Steady-State-Durchsatz
- Tail Latency
- Speichereffizienz
- Parallele Skalierung
2. Einen gemeinsamen Datensatz fixieren
Gleichen Sie die Eingabedaten über einen festen Seed oder eine feste Datei ab. Wird auch die Datengenerierung mitgemessen, muss auch sie in jeder Sprache unter denselben Bedingungen laufen.
3. Zuerst die Korrektheitsprüfung bestehen
Prüfen Sie mit kleinen und großen Daten, dass alle Implementierungen dasselbe Ergebnis liefern. Lässt man einen checksum oder Hash ausgeben, lässt sich das gut handhaben.
4. Build-Bedingungen fixieren
Erzeugen Sie in jeder Sprache eine Release- bzw. optimierte ausführbare Datei und dokumentieren Sie Versionen und Flags.
5. Cold und warm trennen
Besonders bei C# und Java ist dieser Punkt wichtig.
- cold: schließt den Moment direkt nach dem Prozessstart ein
- warm: der stabile Zustand nach mehreren Durchläufen
Zeichnet man auf, bis wohin gemessen wird, wird deutlich, dass diese beiden verschiedene Dinge sind.
flowchart LR
subgraph coldrange["Als cold gemessener Bereich"]
direction LR
s1["Prozessstart"] --> s2["Laufzeitinitialisierung<br/>Class Loading"]
s2 --> s3["Erste JIT-Kompilierung"] --> s4["Erster Durchlauf der Kernverarbeitung"]
end
s4 --> s5["Zweiter und weitere Durchläufe der Kernverarbeitung<br/>Tiered Compilation schreitet fort"]
subgraph warmrange["Als warm gemessener Bereich"]
direction LR
s6["Kernverarbeitung im stabilen Zustand"]
end
s5 --> s6
Da C++ und Go bereits vorab kompiliert sind, gibt es bei ihnen keinen Schritt, der der „ersten JIT-Kompilierung“ entspricht, und auch die Laufzeitinitialisierung fällt vergleichsweise leicht aus. Ein Großteil des Unterschieds bei cold entsteht genau hier. Gerade deshalb ist es sauberer, diese beiden nicht in derselben Tabelle zu vermischen.
6. Die Ausführungsreihenfolge abwechseln oder randomisieren
Beispiel:
cpp -> csharp -> java -> go
go -> java -> cpp -> csharp
csharp -> go -> java -> cpp
...
So verringert sich die Verzerrung durch Wärme und Rauschen.
7. Ausreichend Wiederholungen sicherstellen
Bei einem leichten Microbenchmark sollten es recht viele sein, bei End-to-End-Läufen mindestens 10. Ist der Unterschied klein und die Anzahl der Wiederholungen gering, wird die Interpretation ziemlich unsicher.
8. Rohdaten speichern
Bewahren Sie nicht nur die Aggregatergebnisse, sondern die Rohdaten jedes einzelnen Laufs auf. Sieht man später hinein, lassen sich Ausreißer und Warm-up-Eigenheiten daraus ablesen.
9. Bei Unterschieden ein Profil erstellen
Erst wenn sich ein Unterschied zeigt, gräbt man nach der Ursache.
- CPU-Profil
- Allocation-Profil
- GC-Protokolle
- Flame-Graphs
- Traces auf Betriebssystemebene
Ist man so weit gekommen, kann man nicht mehr nur „schnell / langsam“ sagen, sondern warum es so ist.
Wie man die Ergebnisse liest
Auch wenn die Zahlen vorliegen, ist eine Fehlinterpretation immer noch gefährlich.
Nur beim ersten Lauf sind C# / Java langsam
Der Verdacht fällt auf JIT, Class Loading und Initialisierung. In diesem Fall gilt:
- Zählt die Startzeit, ist es ein bedeutsamer Unterschied
- Steht der Dauerbetrieb im Mittelpunkt, ist es ein Unterschied, der in eine separate Tabelle gehört
C++ ist in tight loops stark
Möglicherweise wirken sich hier Low-Level-Optimierung, Objektanordnung und minimaler Laufzeit-Overhead aus. Allein daraus jedoch zu schließen „also ist es auch im realen Dienst am schnellsten“, ist ein Sprung.
Go wirkt bei Startzeit und Verteilbarkeit im Vorteil
Die einzelne Binärdatei, der vergleichsweise leichte Start und das zugängliche Parallelitätsmodell können sich auszahlen. Das bedeutet aber nicht, dass es bei jedem CPU-lastigen Workload im Vorteil ist.
C# / Java holen im steady state deutlich auf oder überholen sogar
Möglicherweise wirkt hier die JIT-Optimierung. Auch das ist keine seltene Geschichte. Deshalb ist es wichtig, den Vergleich inklusive Start nicht mit dem Vergleich im stabilen Zustand zu vermischen.
Bei allocation-lastigen Prozessen sind die Unterschiede groß
In diesem Fall zählt oft mehr als der Sprachname
- das Speicherlayout
- der Umgang mit Zeichenketten und Maps
- das GC-Verhalten
- überflüssige Kopien
Protokollvorlage
Bewahren Sie bei Benchmark-Ergebnissen mindestens diese Felder auf, das hilft Ihnen später.
timestamp,language,scenario,run_kind,cold_or_warm,elapsed_ms,cpu_ms,max_rss_mb,alloc_bytes,gc_count,checksum
compiler_or_runtime,compiler_version,flags,os,cpu,threads,input_id,notes
run_kind lässt sich beispielsweise so unterteilen.
micromacrostartupparallel
Bei cold_or_warm sollte man unbedingt explizit machen, um welches der beiden es sich handelt.
coldwarm
Was in welche Spalte gehört, sollte vorab festgelegt werden, damit es nicht schwankt.
| Spalte | Inhalt | Formatbeispiel |
|---|---|---|
timestamp |
Startzeitpunkt des Laufs. Dient dazu, spätere Schwankungen je nach Tageszeit nachzuvollziehen | ISO-8601-Format. 2026-03-17T10:00:00+09:00 |
language |
Kennung der Implementierung. Fester Wortschatz, um Schreibvarianten zu vermeiden | cpp / csharp / java / go |
scenario |
Name des Benchmark-Szenarios | sort_int32_10m |
run_kind |
Art der Messung | micro / macro / startup / parallel |
cold_or_warm |
Ob der Start eingeschlossen ist | cold / warm |
elapsed_ms |
Wall-Clock-Zeit. Bis zur dritten Nachkommastelle erspart spätere Probleme | Millisekunden als Dezimalzahl |
cpu_ms |
CPU-Zeit des Prozesses. Summe aus User- und System-Zeit | Millisekunden als Dezimalzahl |
max_rss_mb |
Maximale RSS | Ganzzahl oder Dezimalzahl in MB |
alloc_bytes |
Gesamtzahl der zugewiesenen Bytes. Bei Sprachen, aus denen sich das nicht auslesen lässt, leer lassen und das Leerbleiben selbst dokumentieren | Ganzzahl oder leer |
gc_count |
Anzahl der GC-Läufe. Bei C++ immer leer | Ganzzahl oder leer |
checksum |
Für die Korrektheitsprüfung. Die Übereinstimmung bei allen Implementierungen wird separat geprüft | Hexadezimalzeichenkette |
compiler_or_runtime |
Art des Systems | msvc / dotnet / temurin / go |
compiler_version |
Version des Systems. Bis zur Nebenversion angeben | von der jeweiligen Umgebung ausgegebene Versionszeichenkette |
flags |
Optimierungsbedingungen. Etwa /O2, -O3 -flto, Server GC, GOMAXPROCS=8 |
durch Leerzeichen getrennte Zeichenkette |
os / cpu / threads |
Ausführungsumgebung | Betriebssystemname und Build-Nummer, CPU-Modell, Anzahl der verwendeten Threads |
input_id |
Kennung des Datensatzes. Ein Datei-Hash ist zuverlässig | Dateiname und Hash |
notes |
Notizen zu Läufen mit Auffälligkeiten | Freitext |
Bei den Messwertspalten sollten leere Werte erlaubt sein, und das Leerbleiben selbst sollte dokumentiert werden — das ist der entscheidende Punkt. „gc_count gibt es nicht, weil es C++ ist“ ist eine Information, aber löscht man die Spalte gleich ganz, lässt sich das später nicht mehr erkennen.
Was man übersieht, wenn man nur den Mittelwert betrachtet
Fasst man bei der Aggregation nur zu einem einzigen Mittelwert zusammen, geht Information verloren. Die folgenden Zahlen dienen nur der Veranschaulichung der Rechnung und stammen von keiner realen Implementierung. Angenommen, es handelt sich um elapsed_ms aus 10 nach Geschwindigkeit sortierten Läufen einer einzelnen Implementierung.
98, 99, 100, 101, 101, 102, 103, 104, 106, 720
Aus diesen 10 Werten ergeben sich folgende Kennzahlen.
| Kennzahl | Wert | Lesart |
|---|---|---|
| Mittelwert | 163,4 | Wird vom letzten Ausreißer nach oben gezogen und liegt rund 60 % über dem tatsächlichen üblichen Bereich |
| Median | 101,5 | Kommt dem Gefühl von 9 der 10 Läufe nahe |
| min / max | 98 / 720 | Der Unterschied beträgt mehr als das 7-Fache — ein Signal, zuerst der Ursache des Ausreißers nachzugehen |
| p95 / p99 | nicht ermittelbar | Bei 10 Stichproben haben weder das 95.- noch das 99.-Perzentil eine Bedeutung |
Das heißt: Eine Tabelle, die nur den Mittelwert zeigt, gibt „einer Implementierung, die gelegentlich stark stockt“ und „einer Implementierung, die stabil etwas langsamer ist“ dasselbe Gesicht. Für den Aufbau der Tabelle zählen folgende Unterschiede.
| Aspekt | Schwache Ergebnistabelle | Brauchbare Ergebnistabelle |
|---|---|---|
| Kennzahl | nur der Mittelwert | Median als Hauptwert, min / max und Streuung zusätzlich angegeben |
| Anzahl der Läufe | nicht angegeben | Anzahl der Läufe und ob Ausreißer entfernt wurden, dokumentiert |
| cold / warm | vermischt oder nicht unterschieden | separate Tabelle oder separate Zeilen |
| Korrektheit | nicht erwähnt | dokumentiert, dass die checksums bei allen Implementierungen übereinstimmen |
| Bedingungen | „gemessen auf demselben PC“ | Betriebssystem, CPU, Version des Systems, Optimierungs-Flags, Thread-Anzahl bis ins Detail angegeben |
| Rohdaten | nur Aggregatwerte | Speicherort des Roh-CSV zusätzlich angegeben |
Wollen Sie p95 oder p99 angeben, müssen Sie ohnehin die Anzahl der Läufe erhöhen. Um über eine Verteilung sprechen zu können, braucht man so viele Stichproben, dass die Verteilung überhaupt sichtbar wird — mehr steckt nicht dahinter.
Bei Benchmarks ist häufig die spätere Interpretierbarkeit wichtiger als der Messvorgang selbst.
Zusammenfassung
Was bei einem Geschwindigkeitsvergleich von C# / C++ / Java / Go wirklich zählt, ist, die grobe Frage welche Sprache am schnellsten ist in die Form eines Experiments zu bringen: welchen Workload man unter welchen Bedingungen mit welcher Kennzahl vergleicht.
Besonders schwer daneben liegt man bei folgenden Punkten.
- Startzeit und stabilen Zustand trennen
- Mit demselben Algorithmus, derselben Eingabe und derselben Korrektheitsprüfung messen
- Nie aus einem einzigen Benchmark ein Fazit ziehen
- Sprachinterne Benchmarks von sprachübergreifenden Benchmarks trennen
- Eher Median und Verteilung als den Mittelwert betrachten
- Bedingungen und Rohdaten aufbewahren
Und am Ende das Wichtigste von allem: Versuchen Sie nicht zu sehr, anhand des Sprachnamens Sieger und Verlierer zu bestimmen. Die reale Leistung ergibt sich aus dem Zusammenspiel von Sprache, Laufzeitumgebung, Bibliotheken, Build-Bedingungen, Daten, Betriebssystem und Hardware.
„C++ ist schnell“, „Java ist stark“, „Go ist leichtgewichtig“, „C# ist auch ausreichend schnell“ — all das ist in gewissem Sinne richtig. Fehlt aber, unter welchen Bedingungen das gilt, endet die Diskussion meist aneinander vorbei.
Bedingungen abgleichen, mehrere Workloads verwenden, cold / warm trennen und bis hinunter zur Verteilung schauen. Unscheinbar, aber am Ende ist genau das am wirkungsvollsten.
Quellen
-
BenchmarkDotNet Getting Started https://benchmarkdotnet.org/articles/guides/getting-started.html
-
BenchmarkDotNet Setup and Cleanup (Bedingungen, unter denen
[IterationSetup]verwendet werden darf) https://benchmarkdotnet.org/articles/features/setup-and-cleanup.html -
OpenJDK JMH Project https://openjdk.org/projects/code-tools/jmh/
-
JMH
Leveljavadoc (Einschränkungen und Hinweise zuLevel.Invocation) https://javadoc.io/doc/org.openjdk.jmh/jmh-core/latest/org/openjdk/jmh/annotations/Level.html -
JMH GitHub Repository / README https://github.com/openjdk/jmh
-
Go
testingpackage https://pkg.go.dev/testing -
Go
benchstathttps://pkg.go.dev/golang.org/x/perf/cmd/benchstat -
Google Benchmark User Guide https://google.github.io/benchmark/user_guide.html
-
Wie man die Ausführungsgeschwindigkeit verschiedener Programmversionen unter Windows vergleicht /de/blog/windows-benchmark-comparing-program-versions/
Verwandte Themen
Seiten, die im Zusammenhang mit diesem Artikel das Verständnis erleichtern.
Beratung zu diesem Thema
Design von Leistungsvergleichen, das Angleichen der Messbedingungen, die Interpretation der Ergebnisse und die Ursachenanalyse passen gut zu den folgenden Leistungen.
Verwandte Artikel
Aktuelle Artikel mit denselben Schlagwörtern führen zu verwandten Themen weiter.
Abwärtskompatibilität von DLL- und COM-Schnittstellen — Eine Entscheidungstabelle dafür, welche Änderungen Aufrufer brechen
Welche Änderungen an einer DLL oder COM-Komponente brechen tatsächlich deren Aufrufer? Wir legen die drei Ebenen der Kompatibilität dar —...
Eine Checkliste für den sicheren Umgang mit Kindprozessen in Windows-Apps
Beim sicheren Umgang mit Kindprozessen in Windows-Apps zählt weniger die Start-API als der Besitz des Prozessbaums und das Design der Bee...
Fallstricke bei Shared Memory und Best Practices für die Praxis
Wir ordnen die Fallstricke beim praktischen Einsatz von Shared Memory und ein Design ein, das die Unfallrate senkt – von Synchronisation ...
Wie man die Geschwindigkeit verschiedener Programmversionen unter Windows korrekt vergleicht
Ein reproduzierbares Verfahren zum Vergleich von Programmversionen unter Windows — mit power mode, power plan, Wärmeentwicklung, Hintergr...
Eine C# Native AOT DLL aus C/C++ aufrufen
Wie man eine C#-Klassenbibliothek mit Native AOT als native DLL veröffentlicht und Einstiegspunkte mit UnmanagedCallersOnly aus C/C++ auf...
Verwandte Themen
Diese Seiten ordnen den Artikel in einen größeren Leistungs- und Entscheidungskontext ein.
Technische Windows-Themen
Portal zu Windows-Entwicklung, Fehleranalyse und der Nutzung bestehender Assets.
Leistungen zu diesem Thema
Dieser Artikel ist direkt mit den folgenden Leistungen verbunden.
Technische Beratung und Design-Review
Design von Leistungsvergleichen, das Angleichen der Messbedingungen bis hin zum Lesen von Warm-up und Statistik passen gut zu technischer Beratung und Design-Reviews.
Fehleruntersuchung und Ursachenanalyse
Die Ursachenanalyse von Leistungsunterschieden zwischen Sprachen und Versionen, das Aufspüren von Engpässen und die Prüfung der Validität des Messverfahrens lassen sich gut als Fehleruntersuchung und Ursachenanalyse angehen.
Häufige Fragen
Fragen, die in Beratungen zu diesem Artikelthema häufig gestellt werden.
- Welche Sprache ist am schnellsten: C#, C++, Java oder Go?
- Das lässt sich nicht mit einer einzigen Zahl entscheiden. Die reale Leistung ergibt sich aus dem Zusammenspiel von Sprache, Laufzeitumgebung, Bibliotheken, Build-Bedingungen, Daten, Betriebssystem und Hardware. Entscheidend ist, die Frage „welche Sprache ist am schnellsten“ in die Form eines Experiments zu bringen: „welchen Workload, unter welchen Bedingungen, mit welcher Kennzahl vergleicht man“. Bei der Startzeit werden JIT- und Initialisierungskosten von C# und Java gut sichtbar, während sich im steady state die JIT-Optimierung stark bemerkbar macht und Java/C# durchaus aufholen oder sogar überholen können.
- Warum trennt man bei Benchmarks für C# und Java das Warm-up ab?
- C# und Java unterliegen normalerweise dem Einfluss des JIT, sodass beim Messen des ersten Laufs nicht nur die Geschwindigkeit des Programms selbst gemessen wird, sondern auch der Start der Laufzeitumgebung, das Class Loading und die JIT-Vorbereitung. C++ und Go hingegen sind normalerweise bereits vorab kompiliert. Cold und warm sind beide aussagekräftig, aber nicht in derselben Weise, daher behandelt man cold — das den Moment direkt nach dem Prozessstart einschließt — und warm — den stabilen Zustand nach mehreren Durchläufen — als getrennte Dinge und mischt sie nicht in derselben Tabelle.
- Wie sollte man ein sprachübergreifendes Benchmark gestalten?
- Eine zweischichtige Struktur ist empfehlenswert. Für die sprachinterne Messung verwendet man die zur jeweiligen Sprache passende Harness, etwa BenchmarkDotNet (C#), JMH (Java), go test -bench mit benchstat (Go) oder Google Benchmark (C++). Für den sprachübergreifenden Vergleich ist es riskant, die Ergebnisse der einzelnen Harnesses einfach nebeneinanderzulegen; sinnvoller ist es, jede Implementierung zu einer ausführbaren Datei mit demselben CLI-Vertrag zu machen und von einem gemeinsamen äußeren Runner aus die Ausführungsreihenfolge zu randomisieren, cold und warm zu trennen, denselben Datensatz zu verwenden, den checksum zu prüfen und die Rohdaten zu speichern.
- Worauf muss man bei Microbenchmarks in C++ achten?
- Man muss auf die Falle achten, dass Verarbeitung durch Optimierung verschwindet. Entscheidet der Compiler, dass „das Ergebnis dieser Berechnung niemand verwendet“, kann er die Verarbeitung selbst entfernen — dann misst man nicht Schnelligkeit, sondern schlicht, dass gar nichts getan wird. Deshalb sind die Verwendung des Ergebnisses, die Ausgabe eines checksums und die Funktionen zur Optimierungsunterdrückung des Benchmark-Frameworks wichtig. Da sich Bedingungsunterschiede in C++ direkt und stark auswirken, ist es zudem sehr wichtig, genau zu dokumentieren, mit welchem Compiler, welchen Flags (-O3/O2, LTO, PGO usw.) und welcher STL gemessen wurde.
Autorenprofil
Profilseite des Artikelautors.
Go Komura
Geschäftsführer von KomuraSoft LLC
Spezialisiert auf Windows-Softwareentwicklung, technische Beratung und Fehleranalyse, insbesondere bei bestehenden Systemen und schwer reproduzierbaren Störungen.