How to Fairly Compare the Execution Speed of C#, C++, Java, and Go

· Updated: · · Benchmark, Performance, C#, C++, Java, Go

Revision history (1 updates, last updated Sep 1, 2026)

A log of the changes made to this article. Where a pre-update version was archived, it stays readable at a permanent DOI link.

Retranslated as a full translation of the Japanese original. The previous English version was an abridgement that carried only part of the source, so sections, tables, Mermaid diagrams, figure captions and FAQ entries were missing. All of them have been restored to match the Japanese original, and the technical claims are the same as in the Japanese version. Read the version before this update (DOI: 10.5281/zenodo.21614529)
First published
Cite this article(DOI: 10.5281/zenodo.21614528)

This article is archived on Zenodo. Below are both the DOI that always resolves to the latest version and the DOI pinned to the version you are reading.

Go Komura (2026). How to Fairly Compare the Execution Speed of C#, C++, Java, and Go. KomuraSoft LLC. https://doi.org/10.5281/zenodo.21614528 https://comcomponent.com/en/blog/2026/03/17/000-language-benchmark-csharp-cpp-java-go/

DOI (latest version)
10.5281/zenodo.21614528
DOI (this version)
10.5281/zenodo.22217166

“C++ is supposed to be fast.” “Go is lightweight in production.” “Java gets really fast on long-running workloads.” “C# is surprisingly strong too, thanks to the .NET JIT.”

You hear this kind of thing all the time. But the single worst thing you can do here is take numbers measured by different people in different environments, line them up, and declare a winner among languages.

C# and Java are easily affected by JIT and warm-up, while C++ and Go are normally compiled ahead of time. The presence and characteristics of GC differ too. Differences in the standard library and the surrounding library implementations matter quite a lot. And even on the same machine, results wobble easily with power settings, heat, background activity, and skew in the input data. It is an unglamorous corner of the field.

Sources of variance in resultsDiagram showing that JIT and warm-up effects, differences in GC and library implementations, and power settings, heat, background noise and skew in the input data all combine so that measured results wobble easily.JIT and warm-up effectsResults wobble easilyGC and library implementation differencesPower, heat, noise, input skewDo not rank languages from numbers taken elsewhere

Figure 1: Precisely because there are so many sources of variance, a comparison assembled from other people’s numbers does not hold.

This article lays out how to compare C# / C++ / Java / Go as fairly as possible. To give away the conclusion first: the most important thing is not to try to settle which language is fastest with a single number.

The subject of this article is, strictly, how to structure the comparison. Lining up environment-dependent numbers gets you nowhere, because a change of conditions flips the ranking easily. For that reason we do not publish a measured ranking here. Instead, we focus on how to design a comparison so that it is actually worth something.

Who this is for

This is written for developers and technical leads who have several candidate languages and want to decide which one to implement in on performance grounds, and for anyone who has to put together measurements solid enough to say we compared the speed in an internal report. It is not a deep dive into one language; the subject is the design of a comparison spanning four languages, so it should read fine even if you only work in one of them.

The code samples use PowerShell for the common runner, and BenchmarkDotNet for C# and JMH for Java as the per-language harnesses. For C++ and Go we only list the conditions to align.

Terms to know up front

A few terms appear in English throughout the article. Here they are up front so the first occurrence does not trip you up.

Term Meaning
p95 / p99 Percentiles. Sort every run from fastest to slowest and take the value at the 95% or 99% position from the bottom. p95 is the value where 5 runs out of 100 are slower than this
RSS (Resident Set Size) The amount a process actually has resident in physical memory. Not the amount of virtual memory reserved, but how much real memory it occupies right now
LTO (Link Time Optimization) Optimization that crosses translation units at link time. -flto on GCC / Clang, /GL with /LTCG on MSVC
PGO (Profile-Guided Optimization) Feeding a profile of branches and calls collected from one run into the optimization decisions of the next build
Tiered Compilation The .NET JIT first runs code that compiles quickly, then re-optimizes only the methods that turn out to be hot. It is one of the main reasons cold and warm differ
Server GC / Workstation GC The GC modes in .NET. Server GC gives each logical processor its own heap and GC thread and leans toward throughput; Workstation GC leans toward responsiveness
GOMAXPROCS The upper limit on the number of OS threads the Go runtime uses to run Go code simultaneously. Parallel benchmarks are not comparable unless this is pinned
cgo The mechanism for calling C code from Go. Enabling it changes call cost, build conditions, and whether static linking is possible

The Conclusion First

In a C# / C++ / Java / Go speed comparison, these seven things are what really matter.

  1. Decide first what kind of speed you want to compare Whether it is startup time, steady-state throughput, p95 latency, or memory efficiency changes how you measure.

  2. Never draw a conclusion from a single benchmark CPU computation, memory allocation, parallel processing, and startup time each make different languages and runtimes look strong.

  3. Separate cold and warm for C# and Java Mixing comparisons that include the first run with steady-state comparisons after warm-up twists the whole discussion.

  4. Measure with the same algorithm, the same input, and the same correctness check “It was not a faster implementation, it was just solving a different problem” is a classic benchmark failure.

  5. Separate per-language microbenchmarks from cross-language end-to-end benchmarks Each language’s dedicated harness is convenient, but a cross-language comparison is better run from an outer common runner.

  6. Look at the median and the distribution, not just the mean A single GC pause or background task landing on one run is enough to wreck the average.

  7. Record the conditions, not just the numbers A benchmark result is a record of the experimental conditions just as much as a record of speed. Results with no documented conditions become quite painful later.

Knowledge map for this article

This article points out how risky it is to compare the execution speed of C#, C++, Java, and Go with a single number, and lays out a measurement design for a fair comparison. C# and Java show a large gap between cold and warm because of JIT compilation and Tiered Compilation, while C++ and Go are normally compiled ahead of time with AOT, so the two groups should not be mixed into the same table. In C++, dead code elimination, in which the compiler removes computation it sees as unused, has to be countered with Google Benchmark and with correctness checks based on a checksum; measurement within a single language should use a dedicated harness such as BenchmarkDotNet, JMH, go test -bench, or Google Benchmark, and comparison across languages is recommended to follow a two-layer structure in which an outer shared runner randomizes the execution order and verifies checksums. Looking beyond the mean to the median and to the distribution of tail latency such as p95 and p99 is set out as a further condition for interpreting the results correctly.

Cross-language benchmarking of C#, C++, Java, and GoDiagram showing how the difference between JIT compilation with Tiered Compilation in C# and Java and ahead-of-time compilation in C++ and Go demands a distinction between cold and warm, how Google Benchmark and checksum verification counter dead code elimination in C++, how per-language harnesses and an outer shared runner form a two-layer structure, and how all of it connects to evaluating the distribution of tail latency.usesusesusesusesusesusesusesusesusesusesusesusesusespreventsmay causepreventsusesusesusesusesusesusesusesusesCross-language benchmark designC#JIT (Just-In-Time) CompilationJavaC++Ahead-of-Time (AOT) CompilationGoTiered CompilationGOMAXPROCScgoBenchmarkDotNetJMH (Java Microbenchmark Harness)go test -bench and benchstatGoogle BenchmarkSteady-State (Warm) MeasurementDead code eliminationChecksum-Based Correctness CheckTail Latency Percentiles (p95/p99)Garbage Collection (GC)

In the diagram a solid line marks a relation that always holds and a dashed line marks a conditional one (the conditions are given per relation on the detail page). The full list of relations (24 in total, with evidence and certainty) and the definitions of the main concepts are collected on the knowledge map detail page (in Japanese). Data: JSON-LD / Turtle

What to Decide First

If you let “fast” stay a single word, things usually go wrong. Start by deciding what you are going to call fast.

For the same program, what you want to look at can differ considerably.

1. Do you want to look at startup time?

For CLI tools, short-lived batch jobs, and helper tools that start once and exit immediately, cold start and process startup are what matter. On this axis, results change dramatically depending on whether JIT and class-loading initialization costs are included.

2. Do you want to look at long-running throughput?

For servers, resident processes, workers, and long-running conversion jobs, steady-state throughput is what counts. In that case, being slow only on the first run is not the point; the question is how high and how steadily it climbs after warm-up.

3. Do you want to look at tail latency?

For APIs, UI, and near-real-time processing, p95 / p99 can matter more than the mean. Even if the average is fast, occasional long stalls hurt in terms of user experience and SLAs.

4. Do you want to include memory efficiency?

If you look only at CPU time and ignore peak RSS, allocation volume, GC count, and GC pauses, you will misjudge how heavy the thing really is in production. “Fast but eats a lot of memory” and “a bit slower but consistently lightweight” can swap places in the ranking depending on the use case.

In short, the question to settle first is

What this comparison should answer is not which language is fast, but which workload, under which conditions, on which metric, it can process faster.

Start collecting numbers while this is still vague and nothing will come together at the end.

Decide what fast means before measuringDiagram showing that how you measure depends on whether you want startup time, steady-state throughput, tail latency or memory efficiency, so before comparing you decide which workload, under which conditions, on which metric you are looking at.What does this comparison need to answerStartup timeSteady-state throughputp95 / p99 latencyMemory efficiency is another axisEach axis changes how you measure

Figure 2: Do not start collecting numbers until the definition of fast is settled.

Why Comparing Languages Is Hard

Mixing JIT and AOT turns it into a different experiment

C# and Java are normally affected by the JIT. C++ and Go, on the other hand, are normally compiled ahead of time.

That means if you measure the first run, you are measuring not only the speed of the program itself but also runtime startup, class loading, and JIT preparation. Conversely, if you only look at fully warmed-up runs, the comparison becomes how far steady-state optimization can go.

Both are meaningful. But they do not mean the same thing.

The JIT group versus the AOT groupDiagram showing that C# and Java are normally affected by the JIT so the first run mixes in runtime startup, class loading and JIT preparation, while C++ and Go are normally compiled ahead of time, which makes a first-run comparison and a post-warm-up comparison two different experiments.C# and Java (normally JIT)The first run mixes in startup and JIT preparationC++ and Go (normally AOT)They run from an ahead-of-time compiled binaryFirst-run and steady-state comparisons are different experiments

Figure 3: Between languages with different execution models, where you start measuring decides what the experiment is about.

Implementation differences routinely outweigh language differences

Even for the same “sort”,

  • one side uses the standard library
  • one side is hand-rolled
  • one side does extra copies
  • one side regenerates the input every time

That alone changes the results considerably.

Moreover, once you get into JSON, compression, cryptography, or regular expressions, library implementation differences matter far more than the language itself. Unless you make explicit what you are measuring, what you intended as a “language comparison” becomes a “library comparison”.

A language comparison quietly becomes a library comparisonDiagram showing that for the same work the results change depending on whether the standard library or a hand-rolled implementation is used and whether there are extra copies or input regeneration, and that for JSON, compression, cryptography and regular expressions the library implementation matters more than the language, so without stating what is measured a language comparison becomes a library comparison.NoYesImplementations that supposedly do the same workImplementation and library differences get mixed inIs it explicit what is being measuredA language comparison that is really a library comparisonThe result can be interpreted as a comparison

Figure 4: Naming what you measure correctly clears up most of the misunderstanding on its own.

C++ has a trap where optimization deletes the work

In microbenchmarks especially, when the compiler decides that nobody is using a computed result, it can eliminate the computation entirely. Then it is not that the code is fast; you are measuring the fact that it is doing nothing at all. The typical symptom is the whole loop disappearing and elapsed time dropping to nearly zero.

This problem shows up especially blatantly in C++, so consuming the result, printing a checksum, or using the benchmark framework’s optimization-suppression facilities is quite important.

The trap where optimization deletes the workDiagram showing that in microbenchmarks the compiler removes the computation when it decides nobody uses the result, so the measurement records the absence of work rather than speed, which makes consuming the result, printing a checksum and using suppression facilities important.Nobody uses the computed resultThe compiler deletes the workElapsed time looks close to zeroNot fast, simply doing nothingPrevent it with checksum output or suppression facilities

Figure 5: When a result looks impossibly fast, first suspect that the work was deleted.

GC is neither a disadvantage nor an advantage; it is a characteristic

C#, Java, and Go have a GC. Reducing that to “it has GC, therefore it is slow” is far too crude.

In practice, what matters more is

  • how large numbers of short-lived objects are handled
  • heap size configuration
  • GC frequency and pauses
  • object layout
  • the allocation habits of libraries

Conversely, C++ lets you control things finely through manual management and RAII, but that also means design and implementation differences show up more easily. In other words, a difference in memory management model is not by itself a verdict of better or worse.

GC is a characteristic, not a handicapDiagram showing that in languages with a GC the handling of short-lived objects, heap configuration, GC frequency and pauses and allocation habits matter more, while C++ offers fine control at the cost of design and implementation differences showing up easily, so a different management model is not a verdict of better or worse.Languages with a GCConfiguration and allocation habits dominateManual management and RAII in C++Controllable, but implementation differences show up easilyA different management model is not a verdict

Figure 6: A way of framing this that avoids settling for “it has GC, therefore it is slow”.

What Not to Do in a Comparison

1. Mixing Debug and Release

This is out of the question. Always align the comparison targets on production-grade optimized builds.

2. Not solving the same problem

Different input formats, different output, error handling on only one side, different memory reuse policies. Leave these alone and you end up measuring differences in requirements, not speed.

3. Running once and drawing a conclusion

A single run is mostly noise.

  • JIT
  • page cache
  • CPU boost
  • heat
  • background tasks
  • GC
  • first-time file reads

All of this gets mixed into that one run.

What gets mixed into a single runDiagram showing that a single run mixes together JIT and first-time file reads, page cache and CPU boost, heat, background tasks and GC, which is why one run is mostly noise.JIT and first-time file readsA single run mixes all of it togetherPage cache and CPU boostHeat and background tasksA single run is mostly noise

Figure 7: A number from one run carries the largest share of everything you did not want to measure.

4. Blurring warm-up

When measuring C# and Java, being vague about whether the first run is included or only post-warm-up runs count makes the discussion collapse. Treat cold and warm as separate things.

5. Skipping correctness checks

Before “fast”, a benchmark needs “returns the same result”. Always confirm that every implementation under comparison produces the same checksum or the same output from the same input.

6. Building a worldview from a single microbenchmark

Winning a tight loop does not mean winning across a real service. Conversely, losing on startup time can still leave you plenty strong on long-running workloads.

The Basic Approach When Comparing C# / C++ / Java / Go

This part is quite important. The recommendation is a two-layer structure.

Inner layer: the per-language deep diveOuter layer: the cross-language common runnermeasure the same work in detail inside the languagemeasure the same work in detail inside the languagemeasure the same work in detail inside the languagemeasure the same work in detail inside the languageGoogle BenchmarkBenchmarkDotNetJMHgo test -bench and benchstatCommon runnerrandomized execution order / cold and warm separatedchecksum verification / raw data savedbench executableC++bench executableC#bench executableJavabench executableGo

Figure 8: Cross-language comparison runs in the outer common runner; per-language digging happens in the dedicated harnesses.

Numbers from the outer layer are numbers you may compare across languages; numbers from the inner layer are numbers for tracking improvement within one language. The key point is not to mix the two into a single table.

1. For per-language measurement, use the harness suited to that language

Each language has a benchmark tool that absorbs that language’s own circumstances.

  • C#: BenchmarkDotNet
  • Java: JMH
  • Go: go test -bench and benchstat
  • C++: Google Benchmark

These take care of each runtime’s quirks, the statistical processing, and the common measurement traps to a reasonable degree. They are quite effective for comparisons within a language and for drilling into an implementation.

For instance, measuring “sort 10 million int32 values” with BenchmarkDotNet looks like this in its minimal form.

// 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]                       // also report allocation volume and GC counts
public class SortBench
{
    private int[] _source = Array.Empty<int>();
    private int[] _work = Array.Empty<int>();

    [GlobalSetup]
    public void Setup()
    {
        var rng = new Random(12345);    // fixed seed so the input is identical every time
        _source = new int[10_000_000];
        for (int i = 0; i < _source.Length; i++)
        {
            _source[i] = rng.Next();
        }
        _work = new int[_source.Length];
    }

    [IterationSetup]                    // restore the unsorted state before every iteration
    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;                // return the result so the optimizer cannot delete it
    }
}

[IterationSetup] comes with a constraint. The official BenchmarkDotNet documentation does not recommend it for microbenchmarks because it pollutes the results, and states that it is useful for macrobenchmarks that take 100ms or more. Sorting 10 million values meets that condition, but when you measure short operations, move the work into [GlobalSetup] and carry the state across instead.

With JMH on Java, the thinking is the same.

// Java / JMH. Add jmh-core and jmh-generator-annprocess to pom.xml
//   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 to even out JIT luck
public class SortBench {

    private int[] source;
    private int[] work;

    @Setup(Level.Trial)
    public void setUp() {
        Random rng = new Random(12345);  // fixed seed so the input is identical every time
        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)             // restore the unsorted state before every invocation
    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;                 // JMH consumes the return value, so it is not optimized away
    }
}

Level.Invocation carries the same kind of constraint. The JMH javadoc states explicitly that this level can only be used for benchmarks where a single @Benchmark method call takes more than one millisecond. It takes a timestamp on every invocation, so for short operations the measurement itself becomes the bottleneck.

The @Warmup / @Measurement / @Fork values in BenchmarkDotNet and JMH are experimental conditions in their own right. Always keep them with the results.

2. For cross-language comparison, put a common runner on the outside

On the other hand, placing BenchmarkDotNet results from C# next to JMH results from Java as-is is a bit dangerous. The harnesses themselves follow different conventions.

So for cross-language work, the recommendation is to turn each implementation into an executable that can be invoked through the same CLI contract and drive them all from the outside under identical conditions.

For example, prepare an executable of this shape in each language.

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

Make the output a contract as well. If every implementation, in every language, promises to print exactly these two lines on standard output, the runner can parse them with a single regular expression per line.

checksum=[hex string]
inner_ms=[milliseconds as a decimal]

checksum is for the correctness check, and inner_ms is the time the implementation measured for the core work itself. The runner separately measures the wall-clock time of the whole process, so the time including startup and the time for the body alone both survive.

The CLI contract and the output contractDiagram showing that turning each language implementation into an executable answering the same CLI contract and printing only the two lines checksum and inner_ms lets the runner measure whole-process time separately, so both the startup-inclusive time and the body-only time are preserved.Executables sharing one CLI contractPrint the two lines checksum and inner_msThe runner measures whole-process wall-clockBoth startup-inclusive and body-only times survive

Figure 9: Once the call and the output are contracts, all four languages can run on the same footing.

Then, on the common runner side,

  • randomize the execution order
  • separate cold from warm
  • pass the same dataset
  • verify the checksum
  • collect wall-clock time and memory
  • keep the raw data in CSV / JSON

is the flow. Written out as a skeleton, it looks like this.

# run-bench.ps1 : the cross-language common runner (skeleton)
# Runs on 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"
)

# The executable for each language. The CLI contract is identical across all four implementations.
# For Java, place a thin wrapper around java -jar so that the invocation is uniform.
$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 and $Dataset come from the param block at the top of the script
    $sw = [System.Diagnostics.Stopwatch]::StartNew()
    $stdout = & $Impl.Exe --scenario $Scenario --dataset $Dataset --mode $Mode
    $sw.Stop()
    $exitCode = $LASTEXITCODE

    # The output contract with the implementations is parsed here, in exactly one place
    $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 / run $Index failed. exit=$exitCode"
    }
    # An implementation that forgets to emit inner_ms still returns exit code 0 with only a checksum.
    # Without this guard the NaN goes straight into the CSV and the inner-time comparison
    # breaks silently
    if ([double]::IsNaN($innerMs) -or [double]::IsInfinity($innerMs) -or $innerMs -lt 0) {
        throw "$($Impl.Language) / $Mode / run $Index did not return inner_ms (value: $innerMs). " +
              "The output contract is the two lines checksum= and 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. Correctness first. If the implementations do not all return the same checksum,
#    there is no point measuring speed
$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 mismatch. $($impl.Language) returned $($r.checksum), the baseline is $expected"
        }
    }
}

# 2. The real thing. Shuffle the execution order on every run to even out heat and
#    time-of-day bias. The check in step 1 applied to warm runs that get discarded, so
#    every recorded run is verified against the checksum too. Skip this and the timings
#    of an implementation that started doing different work partway through end up in the
#    CSV, which invalidates the comparison itself
$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 mismatch. $($impl.Language) $mode run #$i returned $($r.checksum), the baseline is $expected"
            }
            $rows.Add($r)
        }
    }
}

# 3. Always keep the raw data. Aggregation happens later, against this CSV.
# Given a name with no parent directory such as -OutCsv raw.csv,
# Split-Path -Parent returns an empty string and New-Item rejects it.
# This blows up after every run has finished, so the whole measurement is lost
$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 "raw data: $OutCsv / $($rows.Count) rows"

There are three things this deliberately does.

  • It puts the correctness check ahead of the speed measurement. Comparing speed while the checksums disagree means nothing
  • It shuffles on every single run. The goal is to avoid running all of A and then all of B
  • It writes out raw data only, with no aggregation. Means and medians are computed from the CSV afterwards

Doing this makes it much easier to keep best practices inside each language and fairness across languages as separate concerns.

Three deliberate choices in the common runnerDiagram showing that the common runner deliberately places the checksum correctness check ahead of speed measurement, shuffles the execution order on every run, and writes raw data without aggregating it.Correctness check before speedA measurement that can be compared fairlyShuffle on every runWrite raw data without aggregatingEvens out heat and time-of-day bias

Figure 10: The runner’s job is not to run fast but to remove doubt.

A Concrete Example: What Benchmark Scenarios to Prepare

When someone says “I want to compare C# / C++ / Java / Go”, the recommendation is: if you only run one, pick a simple CPU-bound scenario that is hard to misread; if you run several, prepare three or four workloads with different characters.

1. sort_int32_10m

Purpose: observe CPU, memory bandwidth, and the use of temporary storage

  • Input: 10 million int32 values generated with a fixed seed
  • Processing: sort the array and return a checksum
  • Caveat: restore the same unsorted input every time

This one is relatively easy to read. It does include differences between standard sort implementations, though, so it is a comparison including the standard library rather than the language itself.

2. hash_group_count

Purpose: observe hash tables, string processing, allocation, and GC tendencies

  • Input: a fixed text dataset
  • Processing: count the occurrences of each word
  • Output: the top N entries plus a checksum

This is close to real work, but differences in string libraries and map implementations also matter a lot. In exchange, it is a more realistic comparison.

3. parallel_sha256

Purpose: observe parallelism, the scheduler, worker pools, and synchronization habits

  • Input: a sequence of fixed-size binary chunks
  • Processing: hash them in order across N threads and return a final checksum
  • Conditions: step the thread count through 1 / 2 / 4 / 8

Compared with a simple tight loop, this makes how well it scales under parallel execution much easier to see.

4. startup_noop or startup_parse_small

Purpose: observe startup time

  • noop: start and exit immediately
  • parse_small: process one small input and exit

Here the JIT and initialization costs of C# / Java are easy to see, and the picture differs quite a bit from C++ / Go. Put the other way round, a gap here is a separate question from who wins on long-running work.

Four benchmark scenarios with different charactersDiagram showing a lineup of four scenarios that each reveal something different, where sorting shows CPU and memory bandwidth, word counting shows hashing, strings and allocation, parallel hashing shows scaling under parallel execution, and the startup benchmark shows startup time.sort_int32_10mCPU, bandwidth, temporary storagehash_group_countStrings, maps, GC tendenciesparallel_sha256Scaling under parallel executionstartup scenariosStartup and initialization cost

Figure 11: One scenario cannot show everything, so split the scenarios by what you want to see.

What about JSON and HTTP benchmarks?

JSON and HTTP are close to real work, so of course they are meaningful. In that case, though, it becomes a comparison that includes libraries, frameworks, and the ecosystem rather than a comparison of languages.

That is not a bad thing in itself. In practice it is often the more important question. But in an article or a report, stating it explicitly

This is not a comparison of languages but a comparison of typical implementations together with the major libraries

leaves less room for misunderstanding.

Conditions to Align per Language

C++

  • Align on optimized builds
  • Pin the compiler
  • Pin the standard library implementation
  • Document conditions such as -O3 / /O2, LTO, and PGO
  • Take care that results are not optimized away
  • Suspect undefined behavior when something looks suspiciously fast

C++ offers a lot of freedom, which means differences in conditions show up directly. That makes which compiler, which flags, and which STL you measured with quite important.

C#

  • Align on Release builds
  • Pin the .NET version
  • Record conditions such as Server GC / Workstation GC
  • Document whether Tiered Compilation, ReadyToRun, and Native AOT are in play
  • Separate cold and warm

For C#, differences in .NET configuration change how things look. In particular, JIT-compiled C# and Native AOT C# are different axes even though both are “C#”. Mix them and what you are comparing is no longer the language but the deployment form.

Java

  • Pin the JDK vendor and version
  • Document the GC
  • Pin the warm-up, measurement, and fork settings
  • Record the heap size and JVM options
  • Separate cold start from steady state

Java benefits readily from the JIT, but in exchange how it looks on the first run varies considerably. That makes separating short-lived process comparisons from long-running comparisons mandatory.

Go

  • Pin the Go version
  • Pin GOMAXPROCS
  • Document CGO_ENABLED
  • If you touch GOGC, always record it
  • Keep benchmark-format output where possible

Go is relatively easy to handle, but in parallel benchmarks the influence of GOMAXPROCS is large. Whether or not you use cgo also changes the whole picture, so always record that among the conditions.

How to Align the Execution Environment

In any language, a comparison run without aligning the environment is mostly a comparison of environments.

What an unaligned comparison really measuresDiagram showing that when the CPU, OS, power conditions, input data, process priority and core count are not aligned, the difference in the results is a difference in environments rather than a difference between languages.Environment alignedNot alignedTwo measurements on environments that do not matchA difference appears in the resultsWhat is that difference a difference inReadable as an implementation or language differenceOnly a comparison of environments

Figure 12: You may blame a difference on the language only after the environmental difference has been removed.

Things to align

  • Same CPU / memory / storage
  • Same OS version
  • Same power conditions
  • Conditions close to the same room temperature
  • Same input data
  • Same process priority
  • Same core-count conditions
  • Same container or bare-metal conditions

Things that matter especially

Power settings and CPU frequency

On a laptop, AC power versus battery alone puts you in a different world. If the CPU governor or power mode is not aligned, comparison results wobble considerably.

Power conditions on Windows, notifications, background noise, heat, and how to align execution order are covered in detail in a separate article, How to Correctly Compare the Speed of Different Program Versions on Windows. If you measure on Windows, that side of things matters a lot.

Heat

If only the first few runs are fast and later ones fall off, suspect heat and throttling. Rather than running all of A and then all of B, alternating like A / B / A / B reduces the bias.

Heat bias and execution orderDiagram showing that when only the later runs degrade you suspect heat and throttling, and that instead of running all of A and then all of B you alternate A and B to reduce the bias.Only the later runs degradeSuspect heat and throttlingDo not run all of A and then move to BAlternate A and B

Figure 13: Heat cannot be removed, but a well-chosen order distributes it fairly between both sides.

Background activity

Updates, indexing, sync, virus scans, browsers, chat tools. This is unglamorous stuff, but it interferes all the time.

What to Measure

For language comparisons, the recommendation is to look at at least these four separately.

1. wall-clock time

The real time the user waits. This is the first metric to look at.

2. CPU time

“How much CPU was actually consumed.” If only the wall-clock time improves while CPU time stays the same, the difference may come from waiting or I/O.

3. memory / allocations

  • peak RSS
  • total allocation volume
  • allocation count
  • GC count
  • GC pauses

Looking at these reveals the cost behind the speed.

4. Distribution

  • median
  • p95 / p99
  • min / max
  • standard deviation and spread

Talk in averages only and you never see what is really going on in the runs that occasionally spike.

Four metrics to look at separatelyDiagram showing that a language comparison looks separately at wall-clock time as the real time the user waits, CPU time as the time actually spent on the CPU, memory including peak RSS and allocations, and the distribution including the median and percentiles.wall-clock timeLook at the four separatelyCPU timeMemory and allocationsDistribution (median, p95) in its own frame

Figure 14: There is no single number for speed; it only becomes readable once the costs behind it are laid out alongside.

The flow that works well in practice goes roughly in this order.

NoYesNoYesNoYes1. Decide the workload2. Fix a common dataset3. Pass the correctness check firstDid every implementationagree on the checksum4. Pin the build conditions5. Separate cold and warm6. Run with a randomized orderReached the requirednumber of runs8. Save the raw dataDid a meaningfuldifference appearRecord the conditions and counts and stop9. Profile and dig into the cause

Figure 15: The procedure from deciding the workload through the correctness check and the randomized real run to saving the raw data.

1. Decide the workload

First, make explicit what you want to compare.

  • startup time
  • steady-state throughput
  • tail latency
  • memory efficiency
  • parallel scaling

2. Fix a common dataset

Align the input data with a fixed seed or a fixed file. If data generation is inside the measurement, that too has to run under the same conditions in every language.

3. Pass the correctness check first

Confirm that every implementation returns the same result on both small and large data. Having them emit a checksum or a hash makes this easy to handle.

4. Pin the build conditions

Produce Release, optimized executables in each language, and record the versions and flags.

5. Separate cold and warm

This is especially important for C# and Java.

  • cold: includes the moment right after process startup
  • warm: the stable state after several runs

Drawing where the measurement starts and stops makes it obvious that these two are different things.

The range measured as warmThe range measured as coldThe body at steady stateRuntime initializationclass loadingProcess startupFirst run of the bodyFirst JIT compilationSecond and later runs of the bodyTiered Compilation progresses

Figure 16: cold spans process startup through the first JIT compilation, while warm measures only the steady state.

Because C++ and Go are compiled ahead of time, they have no step corresponding to “first JIT compilation”, and their runtime initialization is comparatively light. Much of the cold difference is born right there. That is exactly why these two are cleaner kept out of the same table.

6. Alternate or randomize the execution order

For example:

cpp -> csharp -> java -> go
go -> java -> cpp -> csharp
csharp -> go -> java -> cpp
...

This reduces bias from heat and noise.

7. Secure enough runs

For a lightweight microbenchmark, run a great many; for end-to-end runs, you want at least 10. When the difference is small and the run count is low, the interpretation gets quite shaky.

8. Save the raw data

Keep the raw data for every run, not just the aggregates. Looking back later, you can read outliers and warm-up quirks out of it.

9. Profile when a difference appears

Only when a difference appears do you start digging into the cause.

  • CPU profile
  • allocation profile
  • GC logs
  • flame graphs
  • OS-level traces

Once you get this far, you can talk about why it happens rather than just “fast or slow”.

How to Read the Results

Even after the numbers are in, reading them wrong is still dangerous.

C# / Java are slow only on the first run

Suspect JIT, class loading, and initialization. In that case it is

  • a meaningful difference if startup time matters
  • a difference that belongs in a separate table if long-running operation is the subject

C++ is strong in tight loops

Low-level optimization, object layout, and minimal runtime overhead may be paying off. Looking only at that and concluding “therefore it is the fastest in a real service too” is a leap.

Go looks favorable on startup time and ease of distribution

The single binary, the relatively light startup, and the approachable concurrency model can pay off. That does not mean it is favorable on every CPU-bound workload.

C# / Java catch up considerably at steady state, or overtake

JIT optimization may be kicking in. This is not a rare story either. That is why it matters not to mix startup-inclusive comparisons with steady-state comparisons.

Large differences on allocation-heavy work

In this case, more than the language name, what usually matters is

  • memory layout
  • how strings and maps are handled
  • GC behavior
  • extra copies
How to read a difference when one appearsDiagram showing that when C# or Java is slow only on the first run you suspect JIT and initialization, treat it as a meaningful difference if startup time matters and as a difference for a separate table if long-running operation is the subject, and dig into the cause with a profile.Startup includedSteady stateA difference appearedUnder which conditionMeaningful if startup time is the subjectTreat it as a long-running comparisonDig into the cause with a profile

Figure 17: Before comparing the size of the numbers, confirm which arena the difference belongs to.

A Recording Template

Keeping at least this much with every benchmark result will help you later.

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

For example, run_kind can be split like this.

  • micro
  • macro
  • startup
  • parallel

For cold_or_warm, you definitely want to state which one it is.

  • cold
  • warm

Decide up front what goes into each column and the entries stay consistent.

Column What goes in it Format example
timestamp The run’s start time. Used later to see variation by time of day ISO 8601 format. 2026-03-17T10:00:00+09:00
language The implementation identifier. A fixed vocabulary prevents spelling drift cpp / csharp / java / go
scenario The benchmark scenario name sort_int32_10m
run_kind The kind of measurement micro / macro / startup / parallel
cold_or_warm Whether startup is included cold / warm
elapsed_ms Wall-clock. Keeping three decimal places saves trouble later Milliseconds as a decimal
cpu_ms The process CPU time. User plus system Milliseconds as a decimal
max_rss_mb Peak RSS MB as an integer or a decimal
alloc_bytes Total allocated bytes. Leave it blank for languages where it cannot be obtained, and keep the blank itself as information Integer, or empty
gc_count GC count. Always blank for C++ Integer, or empty
checksum For the correctness check. Verify separately that it matches across all implementations Hex string
compiler_or_runtime The kind of toolchain or runtime msvc / dotnet / temurin / go
compiler_version The toolchain version, down to the minor number The version string the toolchain reports
flags Optimization conditions such as /O2, -O3 -flto, Server GC, GOMAXPROCS=8 Space-separated string
os / cpu / threads The execution environment OS name and build number, CPU model, thread count used
input_id The dataset identifier. A file hash makes it airtight File name and hash
notes Notes about runs that behaved oddly Free text

The key point is to allow measurement columns to be blank and to keep the blank itself on record. “C++ has no gc_count” is information; delete the column entirely and nobody can tell later.

What you miss by looking only at the mean

When you aggregate down to a single mean, information disappears. The following are made-up numbers used to explain the arithmetic, not measured values for any language. Take them as the elapsed_ms of 10 runs of one implementation, sorted from fastest to slowest.

98, 99, 100, 101, 101, 102, 103, 104, 106, 720

Here are the summary statistics those 10 values produce.

Metric Value How to read it
Mean 163.4 Dragged up by the last run, it sits roughly 60% above the range you actually see
Median 101.5 Close to what 9 runs out of 10 feel like
min / max 98 / 720 More than a 7x spread, which is a signal to investigate the outlier first
p95 / p99 Not available With 10 samples, neither the 95th nor the 99th percentile means anything

In other words, a table that shows only the mean gives “an implementation that occasionally stalls badly” and “an implementation that is consistently a bit slower” the same face. In terms of how the table is built, these differences are what count.

Aspect A weak results table A usable results table
Summary statistic The mean only The median as the headline, with min / max and the spread alongside
Number of trials Not stated The run count, and the stated policy on whether outliers were removed
cold / warm Mixed together, or not distinguished Separated into different tables or different rows
Correctness Not mentioned States that the checksum matched across all implementations
Conditions “Measured on the same PC” OS, CPU, toolchain versions, optimization flags, and thread count all recorded
Raw data Aggregates only The location of the raw CSV noted alongside

If you want to publish p95 or p99, you need more runs in the first place. To talk about a distribution you need enough samples for the distribution to be visible is all there is to it.

With benchmarks, being interpretable later often matters more than the act of measuring.

What a single mean hidesDiagram showing that one large outlier pulls the mean well above the normal range while the median stays closer to experience, and that a large gap between min and max is a signal to investigate the outlier, which makes a mean-only table risky.One large outlier appearsThe mean drifts above the normal rangeA mean-only table hides what is happeningReport the median with min and maxAn outlier is a signal to investigate the cause

Figure 18: The mean does not lie, but it stays silent about the outlier.

Summary

What really matters in a C# / C++ / Java / Go speed comparison is taking the crude question of which language is fastest and turning it into the shape of an experiment: which workload, under which conditions, on which metric are we comparing?

These are the points that are hardest to get wrong.

  • Separate startup time from steady state
  • Measure with the same algorithm, the same input, and the same correctness check
  • Never draw a conclusion from a single benchmark
  • Separate per-language benchmarks from cross-language benchmarks
  • Look at the median and the distribution rather than the mean
  • Keep the conditions and the raw data

And the most important thing of all: do not push too hard to settle winners and losers by language name. Real-world performance is decided by the combination of language, runtime, libraries, build conditions, data, OS, and hardware.

“C++ is fast”, “Java is strong”, “Go is lightweight”, “C# is plenty fast too” are all, in a sense, correct. But once under which conditions you are saying so drops out, the discussion ends without the two sides ever meeting.

Align the conditions, use multiple workloads, separate cold from warm, and look all the way down to the distribution. It is unglamorous, but in the end this is what works best.

Recasting a crude question as an experimentDiagram showing that the crude question of which language is fastest is recast as an experiment asking which workload under which conditions on which metric, and that aligning the conditions, using multiple workloads, separating cold from warm and looking at the distribution is what works best.Which language is fastestRecast it in the shape of an experimentDecide the workload, the conditions, and the metricRun cold and warm separatelyLook at the distribution and keep it with the conditions

Figure 19: Reframing the question is the single biggest conclusion of this article.

References

  • BenchmarkDotNet Getting Started https://benchmarkdotnet.org/articles/guides/getting-started.html

  • BenchmarkDotNet Setup and Cleanup (the conditions under which [IterationSetup] may be used) https://benchmarkdotnet.org/articles/features/setup-and-cleanup.html

  • OpenJDK JMH Project https://openjdk.org/projects/code-tools/jmh/

  • JMH Level javadoc (the constraints and warnings for Level.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 testing package https://pkg.go.dev/testing

  • Go benchstat https://pkg.go.dev/golang.org/x/perf/cmd/benchstat

  • Google Benchmark User Guide https://google.github.io/benchmark/user_guide.html

  • How to Correctly Compare the Speed of Different Program Versions on Windows /en/blog/2026/03/16/002-windows-benchmark-comparing-program-versions/

Pages that are easier to understand when read together with this article.

Where to Discuss This Topic

Designing performance comparisons, aligning measurement conditions, interpreting results, and digging into root causes are a great fit for the following services.

Recent articles sharing the same tags. Deepen your understanding with closely related topics.

These topic pages place the article in a broader service and decision context.

This article connects naturally to the following service pages.

Frequently Asked Questions

Common questions about the topic of this article.

Which of C#, C++, Java, and Go is the fastest?
A single number cannot settle it, because real-world performance is decided by the combination of language, runtime, libraries, build conditions, data, OS, and hardware. What matters is turning the question of which language is fastest into the shape of an experiment: which workload, under which conditions, on which metric are we comparing? On startup time the JIT and initialization costs of C# and Java are easy to see, while at steady state it is not unusual at all for JIT optimization to close most of the gap or even take the lead.
Why separate warm-up in C# and Java benchmarks?
Because C# and Java are normally affected by the JIT, so measuring the first run captures not only the speed of the program itself but also runtime startup, class loading, and JIT preparation. C++ and Go, by contrast, are normally compiled ahead of time. Cold and warm are both meaningful but they do not mean the same thing, so treat cold, which includes the moment right after process startup, and warm, the stable state after several runs, as separate things and keep them out of the same table.
How should a cross-language benchmark be designed?
A two-layer structure works well. For measurement inside a language, use the harness that fits it: BenchmarkDotNet for C#, JMH for Java, go test -bench with benchstat for Go, and Google Benchmark for C++. For cross-language comparison, lining up the output of those harnesses as-is is risky, so the sound approach is to turn each implementation into an executable that answers to the same CLI contract and let an outer common runner randomize the execution order, separate cold from warm, feed the same dataset, verify checksums, and save the raw data.
What should I watch out for in C++ microbenchmarks?
Watch out for the trap where optimization deletes the work. If the compiler decides that nobody uses the computed result, it can remove the computation itself, and the result you measure is not speed but the absence of work. That is why consuming the result, printing a checksum, and using the optimization-suppression facilities of a benchmark framework matter. C++ also exposes differences in conditions very directly, so stating which compiler, which flags such as -O3 or O2, LTO, and PGO, and which STL you measured with is quite important.

Author Profile

Profile page for the article author.

Go Komura

Representative of KomuraSoft LLC

Focused on Windows software development, technical consulting, and investigations into failures that are difficult to reproduce.

Back to the Blog