公平比較 C#/C++/Java/Go 執行速度的方法

· 更新日期: · · Benchmark, Performance, C#, C++, Java, Go

更新紀錄(2 筆,最後更新 2026年09月04日)

本文的修改紀錄。已保存的更新前版本,可透過附有 DOI 的永久連結閱讀。

已將繁體中文版改寫為日文原文的完整翻譯。先前的繁體中文版只譯出日文原文的一部分,遺漏了章節、表格、Mermaid 圖、圖說與 FAQ。本次依日文原文將這些內容全部補回,並新增本文的知識地圖章節。技術主張與日文版一致。 查看更新前的版本 (DOI: 10.5281/zenodo.22279438)
補上了日文原文中已有的諮詢引導(consultation_services)。內文沒有改動。 查看更新前的版本 (DOI: 10.5281/zenodo.21616315)
初次發布
引用本文(DOI: 10.5281/zenodo.21616314)

本文保存於 Zenodo。以下同時提供一律指向最新版本的 DOI,以及固定於您正在閱讀版本的 DOI。

Go Komura(2026)。〈公平比較 C#/C++/Java/Go 執行速度的方法〉。小村軟體有限公司。https://doi.org/10.5281/zenodo.21616314 https://comcomponent.com/zh-TW/blog/2026/03/17/000-language-benchmark-csharp-cpp-java-go/

DOI(最新版本)
10.5281/zenodo.21616314
DOI(此版本)
10.5281/zenodo.22297153

「聽說 C++ 很快」 「Go 在維運上很輕」 「Java 跑久了相當快」 「C# 因為有 .NET 的 JIT,其實也意外地強」

這類說法很常見。 不過這裡最不能做的,就是把不同的人在不同環境測出來的數字並排,直接當成語言的優劣。

C# 與 Java 容易受到 JIT 與 warm-up 的影響,C++ 與 Go 則通常是事前編譯完成的。 GC 的有無與特性也不一樣。標準函式庫與周邊函式庫的實作差異也相當有影響。而且就算是同一台機器,電源設定、熱、背景處理、輸入資料的偏差都會讓結果輕易浮動。這是個相當瑣碎麻煩的世界。

結果浮動的成因說明 JIT 與 warm-up 的影響、GC 的有無與特性差異、標準函式庫與周邊函式庫的實作差異,加上電源設定、熱、背景處理、輸入資料的偏差疊在一起,測量結果就會輕易浮動的圖。JIT 與 warm-up 的影響結果會輕易浮動GC 與函式庫的實作差異電源、熱、雜訊、輸入偏差不要並排不同環境的數字來決定優劣

圖 1: 正因為浮動的來源太多,把別人的數字並排的比較才不成立。

本文梳理盡可能公平地比較 C# / C++ / Java / Go 的測量方法。 先講結論,不要想用一個數字決定「哪個語言最快」才是最重要的。

本文的主題自始至終都是比較方法的梳理。 就算把依賴環境的數字並排,條件一變就很容易反轉。因此這裡不寫實測排名,改成專注梳理要怎麼設計,比較才會有價值。

目標讀者

本文寫給手上有多個語言候選,想從效能面判斷該用哪一個來實作的開發者與技術主管,以及想在公司內部或報告中,組出能說「我們比較過速度」的測量流程的人。主題不是單一語言的深入探討,而是跨 4 種語言的比較設計,所以只碰其中一種語言的人也讀得下去。

程式碼範例中,共通 runner 用 PowerShell,語言內的 harness 用 C# 的 BenchmarkDotNet 與 Java 的 JMH。C++ 與 Go 只列出條件要怎麼對齊。

先掌握幾個用語

本文有幾個用語會直接以英文出現。為了不在第一次看到時卡住,先整理如下。

用語 意義
p95 / p99 百分位數。把所有 run 由快到慢排列後,位於由下往上 95% / 99% 位置的值。「100 次裡有 5 次比這個慢」就是 p95
RSS (Resident Set Size) 處理程序實際載入實體記憶體的大小。它表示的不是已保留的虛擬記憶體量,而是「現在占用了多少實體記憶體」
LTO (Link Time Optimization) 在連結時跨翻譯單元進行最佳化的機制。GCC / Clang 的 -flto、MSVC 的 /GL 與 /LTCG 都屬於這一類
PGO (Profile-Guided Optimization) 先執行一次,收集分支與呼叫的 profile,再把它用在下一次建置的最佳化判斷上的機制
Tiered Compilation .NET 的 JIT 會先用能快速編譯出來的程式碼執行,之後只針對常被呼叫的方法重新最佳化的機制。它是 cold 與 warm 出現差距的主因之一
Server GC / Workstation GC .NET 的 GC 運作模式。Server GC 為每個邏輯處理器準備堆積與 GC 專用執行緒,偏向吞吐量;Workstation GC 則偏向回應性
GOMAXPROCS Go 的執行階段同時執行 Go 程式碼的 OS 執行緒數上限。平行 bench 若不把這裡固定住,結果就沒辦法比較
cgo 從 Go 呼叫 C 程式碼的機制。啟用之後,呼叫成本、建置條件、能不能靜態連結都會改變

先講結論

C# / C++ / Java / Go 的速度比較中,真正管用的是這 7 件事。

  1. 先決定要比較的是什麼的速度 要看的是啟動時間、穩定狀態的 throughput、p95 延遲,還是記憶體效率,測量方式都不一樣。

  2. 不要只用一個 bench 就下結論 在 CPU 計算、記憶體配置、平行處理、啟動時間上,哪個語言或執行階段看起來強會不一樣。

  3. C# 與 Java 要把 cold 和 warm 分開 把包含第一次執行的比較,和 warm-up 之後的穩定狀態比較混在一起,討論就會走樣。

  4. 用同樣的演算法、同樣的輸入、同樣的正確性檢查來測 不是實作比較快,而是根本在解不同的問題,這是 bench 很常見的狀況。

  5. 把語言內的 microbenchmark 和跨語言的 end-to-end bench 分開 各語言的專用 harness 很方便,但跨語言的比較交給外側的共通 runner 來跑,方向比較對。

  6. 不只看平均,也要看中位數與分布 只要有一次剛好撞上 GC 或背景處理,平均就會壞掉。

  7. 不只留下數字,也要留下條件 bench 結果既是速度的紀錄,同時也是實驗條件的紀錄。沒有寫下條件的結果,之後會相當難處理。

圖中實線表示始終成立的關係,虛線表示附帶條件的關係(成立條件寫在詳細頁面中各關係的說明中)。關係的完整清單(共 24 條,附依據與可信度)以及主要概念的定義,彙整在知識地圖詳細頁面(日文)。資料:JSON-LD / Turtle

最先該決定的事

把「快」用一個詞就打發掉,大概都會出事。 先決定 要把什麼叫做快。

舉例來說,就算是同一個程式,想看的東西也相當不同。

1. 想看的是啟動時間嗎

如果是 CLI 工具、短命的批次、啟動一次就馬上結束的輔助工具,cold start 與 process startup 就派得上用場。 在這條軸上,有沒有把 JIT 與類別載入的初始化成本算進去,結果會差很多。

2. 想看的是長時間運轉的 throughput 嗎

如果是伺服器、常駐處理程序、worker、長時間運作的轉換處理,steady-state 的 throughput 才重要。 這種情況下,只有第一次比較慢本身不是重點,重點是 warm-up 之後能穩定拉高到什麼程度。

3. 想看的是 tail latency 嗎

在 API、UI、偏即時的處理上,p95 / p99 有時比平均更重要。 就算平均很快,只要偶爾大幅停頓,對使用者體驗或 SLA 來說都很難收拾。

4. 想連記憶體效率一起看嗎

不只 CPU 時間,還要看 最大 RSS、配置量、GC 次數、GC pause,否則會誤判維運上的沉重程度。 「快但吃掉不少記憶體」和「稍微慢一點但穩定又輕」,評價會隨用途反轉。

總之,最先該決定的問題是

這個比較想知道的不是哪個語言快,而是 哪個 workload、在哪個條件下、以哪個指標來看能處理得比較快

這才是真正該問的問題。

把這裡放著曖昧不清就開始收集數字,最後會收不攏。

先決定要把什麼叫做快說明想看的是啟動時間、穩定狀態的 throughput、尾端延遲還是記憶體效率,測量方式都會不同,所以比較之前要先決定看哪個 workload、在哪個條件下、用哪個指標的圖。這個比較想知道的是什麼啟動時間穩定狀態的 throughputp95 / p99 的延遲記憶體效率是另一條軸每條軸的測量方式都不同

圖 2: 在「快」的定義決定之前,不要開始收集數字。

為什麼語言比較很困難

混進 JIT 與 AOT,就變成不同的實驗

C# 與 Java 通常會受到 JIT 影響。 另一方面,C++ 與 Go 通常是事前編譯完成的。

也就是說,測量第一次執行時,除了 程式本體的速度,也會把 執行階段的啟動、類別載入、JIT 準備 一起測進去。 反過來,如果只看充分 warm-up 之後,那就變成 穩定狀態下最佳化能發揮到什麼程度 的比較。

兩者都有意義。 但是,意義並不相同。

JIT 組與 AOT 組的差別說明 C# 與 Java 通常受 JIT 影響,第一次執行會混進執行階段啟動、類別載入與 JIT 準備,而 C++ 與 Go 通常是事前編譯完成的,因此測量第一次的比較與測量 warm-up 之後的比較意義並不相同的圖。C# 與 Java〔通常 JIT〕第一次執行會混進啟動與 JIT 準備C++ 與 Go〔通常 AOT〕以事前編譯完成的形式執行第一次的比較與穩定狀態的比較是不同的實驗

圖 3: 執行模型不同的語言之間,從哪裡開始測就決定了實驗的內容。

實作差異大於語言差異,是很常見的事

同樣是「排序」,

  • 一邊用標準函式庫
  • 一邊自己實作
  • 一邊多做了一次複製
  • 一邊每次都重新產生輸入

光是這些,結果就會差很多。

再者,一旦處理變成 JSON、壓縮、加密、正規表示式這一類,函式庫實作 的差異就會比 語言本身 更有影響。 所以不寫清楚在測什麼,本來要做的「語言比較」就會變成「函式庫比較」。

語言比較被偷換成函式庫比較說明同樣的處理會因為用標準函式庫還是自己實作、有沒有多餘的複製或每次重新產生輸入而結果不同,而在 JSON、壓縮、加密、正規表示式上,函式庫實作的差異比語言本身更有影響,所以不寫清楚在測什麼,本來要做的語言比較就會變成函式庫比較的圖。沒有有理應做同樣處理的各個實作實作與函式庫的差異混在一起有沒有寫清楚在測什麼本來要做語言比較卻變成函式庫比較可以當成比較來解讀

圖 4: 只要把測量對象的名字取對,大半的誤解就會消失。

C++ 有「最佳化把處理消掉」的陷阱

特別是在 microbenchmark 中,編譯器一旦判斷「這個計算結果沒有人在用」,就可能把處理消掉。 這樣一來,測到的不是快,而是 根本什麼都沒做 的結果。典型的樣子是整個迴圈都消失,執行時間幾乎歸零。

在 C++ 上這個問題特別容易明顯地出現,所以使用結果、輸出 checksum,或是 benchmark 框架的最佳化抑制功能都相當重要。

最佳化把處理消掉的陷阱說明在 microbenchmark 中,只要編譯器判斷沒有人使用計算結果就會把處理本身消掉,測到的不是快而是什麼都沒做的結果,因此使用結果、輸出 checksum 與最佳化抑制功能變得重要的圖。沒有人使用計算結果編譯器把處理消掉執行時間看起來幾乎為零不是快,而是什麼都沒做以 checksum 輸出與抑制功能防止

圖 5: 快得離譜的結果,先懷疑是不是已經被消掉了。

GC 的存在既不是「不利」也不是「有利」,而是特性

C#、Java、Go 都有 GC。 把它簡化成「有 GC 所以慢」太粗略了。

實際上,

  • 如何處理大量的短命物件
  • 堆積大小的設定
  • GC 的頻率與 pause
  • 物件佈局
  • 函式庫的配置習性

這些的影響更大。

反過來說,C++ 可以用手動管理或 RAII 做細緻的控制,但相對地設計與實作的差異也容易顯現。 也就是說,管理方式的不同,並不直接等於好壞或優劣。

GC 不是不利,而是特性說明在有 GC 的語言上,短命物件的處理方式、堆積設定、GC 的頻率與 pause、配置習性的影響更大,而 C++ 因為能細緻控制,設計與實作的差異也容易顯現,因此管理方式的不同並不直接等於優劣的圖。有 GC 的語言設定與配置習性的影響較大C++ 的手動管理與 RAII可以控制但容易出現實作差異管理方式的不同不等於優劣

圖 6: 為了不要用「有 GC 所以慢」一句話帶過而做的梳理。

比較時不能做的事

1. 混用 Debug 與 Release

這根本不用討論。 比較對象一定要對齊成 等同正式環境的最佳化建置。

2. 沒有在解同一個問題

輸入格式不同,輸出不同,只有一邊沒有錯誤處理,記憶體重複使用的方針不同。 放著這些不管,測到的就會是 需求的差異而不是速度。

3. 只執行一次就下結論

只執行一次,大多是雜訊。

  • JIT
  • 分頁快取
  • CPU 的頻率提升
  • 熱
  • 背景工作
  • GC
  • 第一次的檔案讀取

這些會在同一次執行裡全部混在一起。

只執行一次會混進來的東西說明只執行一次會把 JIT、分頁快取、CPU 的頻率提升、熱、背景工作、GC、第一次的檔案讀取全部混在一起,因此一次的結果大多是雜訊的圖。JIT 與第一次的讀取全部混進同一次執行快取與 CPU 頻率提升熱與背景處理只跑一次的結果大多是雜訊

圖 7: 只有一次的數字,裡面混進最多不想測的東西。

4. 讓 warm-up 混在一起

測量 C# 與 Java 時,要不要包含第一次執行,還是只看 warm-up 之後,這裡一曖昧,討論就會垮掉。 要把 cold 與 warm 當成不同的東西 來處理。

5. 不做正確性檢查

bench 在「快」之前,必須先做到「回傳相同的結果」。 一定要檢查比較對象的所有實作,都能從相同的輸入得到相同的 checksum 或相同的輸出。

6. 只用一個 microbenchmark 就決定整個世界觀

只在 tight loop 上贏,不代表在整個實際服務上也會贏。 反過來說,就算啟動時間輸了,長時間運轉時也可能夠強。

比較 C# / C++ / Java / Go 時的基本方針

這裡相當重要。 建議採用 2 層構成。

內層:語言內的深入探討外層:跨語言的共通 runner在語言內詳細測量同樣的處理在語言內詳細測量同樣的處理在語言內詳細測量同樣的處理在語言內詳細測量同樣的處理Google BenchmarkBenchmarkDotNetJMHgo test -bench 與 benchstat共通 runner執行順序隨機化 / 分開 cold 與 warmchecksum 驗證 / 保存 raw databench 執行檔C++bench 執行檔C#bench 執行檔Javabench 執行檔Go

圖 8: 跨語言的比較交給外層的共通 runner,語言內的深入探討交給專用 harness。

外層產生的數字是 可以跨語言比較的數字,內層產生的數字是 在該語言內追蹤改善用的數字。重點就在於不要把這兩者混進同一張表。

1. 語言內的測量,使用適合該語言的 harness

每個語言都有能替你吸收該語言特有狀況的 benchmark 工具。

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

這些工具會在一定程度上替你處理各自的執行階段狀況、統計處理與測量陷阱。 對 語言內的比較 與 實作的深入探討 相當有效。

舉例來說,用 BenchmarkDotNet 測量「把 1,000 萬筆 int32 排序」,最小構成長這樣。

// C# / .NET 8 + BenchmarkDotNet 0.13 系列
//   dotnet add package BenchmarkDotNet
//   dotnet run -c Release
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

BenchmarkRunner.Run<SortBench>();

[MemoryDiagnoser]                       // 連配置量與 GC 次數也一起輸出
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,讓每次的輸入都相同
        _source = new int[10_000_000];
        for (int i = 0; i < _source.Length; i++)
        {
            _source[i] = rng.Next();
        }
        _work = new int[_source.Length];
    }

    [IterationSetup]                    // 每次都回到未排序的狀態
    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;                // 回傳結果,避免被最佳化消掉
    }
}

[IterationSetup] 有它的限制。BenchmarkDotNet 的官方文件指出,在 microbenchmark 中使用它會汙染結果,因此不建議使用,但對 耗時 100ms 以上的 macrobenchmark 則是有用的。1,000 萬筆的排序滿足這個條件,但要測量短時間的處理時,請改成把狀態放在 [GlobalSetup] 那一側輪替使用的形式。

在 Java 的 JMH 上,要考慮的事情也一樣。

// Java / JMH。在 pom.xml 加入 jmh-core 與 jmh-generator-annprocess
//   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)                                 // 分開 JVM,抹平 JIT 的運氣差異
public class SortBench {

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

    @Setup(Level.Trial)
    public void setUp() {
        Random rng = new Random(12345);  // 固定 seed,讓每次的輸入都相同
        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)             // 每次都回到未排序的狀態
    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 消費,所以不會被最佳化消掉
    }
}

Level.Invocation 也有同一類的限制。JMH 的 javadoc 明確寫著,這個層級 只能用在單次 @Benchmark 方法呼叫超過 1 毫秒的 bench 上。因為它會在每次呼叫時取時間戳記,處理很短時,測量本身就會變成瓶頸。

BenchmarkDotNet 與 JMH 的 @Warmup / @Measurement / @Fork 數值,本身就是 實驗條件。請務必和結果一起留下來。

2. 跨語言的比較,在外側放一個共通 runner

另一方面,把 C# 的 BenchmarkDotNet 結果 與 Java 的 JMH 結果 直接並排,有點危險。 因為 harness 本身的做法就不一樣。

因此在跨語言時,建議把每個實作都做成 能以相同 CLI 合約呼叫的執行檔,再從外側以相同條件執行。

舉例來說,在各語言準備這種形式的執行檔。

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

輸出端也做成合約。只要約定不論哪個語言的實作,都只往標準輸出印出這 2 行,runner 端用一行正規表示式就能解析完。

checksum=[16 進位的字串]
inner_ms=[小數的毫秒]

checksum 用於正確性檢查,inner_ms 是實作自己測量的本體處理時間。runner 端會另外測量整個處理程序的 wall-clock,所以 含啟動的時間 與 只有本體的時間 兩者都會留下來。

CLI 合約與輸出合約說明把各語言的實作做成能以相同 CLI 合約呼叫的執行檔,並約定只往標準輸出印出 checksum 與 inner_ms 兩行,而 runner 另外測量整個處理程序的時間,因此含啟動的時間與只有本體的時間都會留下來的圖。相同 CLI 合約的執行檔印出 checksum 與 inner_ms 兩行runner 測量整體的 wall-clock含啟動與只有本體的時間都留下來

圖 9: 把呼叫方式與輸出做成合約,4 種語言就能在同一個場地上跑。

接著在共通 runner 這一側,

  • 隨機化執行順序
  • 分開 cold / warm
  • 傳入相同的資料集
  • 驗證 checksum
  • 採集 wall-clock 與記憶體
  • 把 raw data 留成 CSV / JSON

做成這樣的流程。只寫出骨架的話,長這樣。

# run-bench.ps1 : 跨語言的共通 runner(骨架)
# 以 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"
)

# 各語言的執行檔。CLI 合約在 4 個實作上完全對齊成一樣。
# Java 則放一層只包住 java -jar 的薄包裝層,把呼叫方式統一起來。
$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 與 $Dataset 使用開頭 param 區塊中定義的變數
    $sw = [System.Diagnostics.Stopwatch]::StartNew()
    $stdout = & $Impl.Exe --scenario $Scenario --dataset $Dataset --mode $Mode
    $sw.Stop()
    $exitCode = $LASTEXITCODE

    # 與實作之間的輸出合約,只在這裡一個地方解析
    $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 失敗了。exit=$exitCode"
    }
    # 忘記輸出 inner_ms 的實作,會以結束代碼 0 只回傳 checksum。
    # 不在這裡擋掉,就會以 NaN 進入 CSV,內側時間的比較會默默壞掉
    if ([double]::IsNaN($innerMs) -or [double]::IsInfinity($innerMs) -or $innerMs -lt 0) {
        throw "$($Impl.Language) / $Mode / run $Index 沒有回傳 inner_ms(值: $innerMs)。" +
              "輸出合約是 checksum= 與 inner_ms= 這 2 行"
    }

    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. 先做正確性檢查。所有實作若沒有回傳相同的 checksum,測速度就沒有意義
$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 不一致。$($impl.Language) 是 $($r.checksum),基準是 $expected"
        }
    }
}

# 2. 正式測量。每個 run 都打散執行順序,抹平熱與時段的偏差
#    步驟 1 的檢查針對的是丟棄的 warm run,所以要記錄的 run 也要
#    一個一個核對 checksum。省掉這裡,中途開始做別的事情的
#    實作的時間就會混進 CSV,比較本身就失效了
$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 不一致。$($impl.Language) 的 $mode run #$i 是 $($r.checksum),基準是 $expected"
            }
            $rows.Add($r)
        }
    }
}

# 3. 一定要留下原始資料。彙總之後再從這份 CSV 進行
# 傳入像 -OutCsv raw.csv 這種沒有父目錄的名稱時,
# Split-Path -Parent 會回傳空字串,New-Item 會拒絕它。
# 在這裡倒下是在所有 run 都跑完之後,所以會整個失去測量結果
$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) 列"

這裡有 3 件事是刻意做的。

  • 把正確性檢查放在速度測量之前。checksum 對不上就只比速度,沒有意義
  • 每個 run 都打散順序。目的是不要變成先把 A 全部跑完再跑 B 的順序
  • 不做彙總,只輸出 raw data。平均與中位數之後再對這份 CSV 計算

這麼做之後,就比較容易把 各語言內部的最佳實踐 與 跨語言的公平性 分開處理。

共通 runner 的 3 個意圖說明共通 runner 會刻意做 3 件事:把以 checksum 進行的正確性檢查放在速度測量之前、每個 run 都打散執行順序、不做彙總只輸出 raw data 的圖。正確性檢查放在速度之前能公平比較的測量每個 run 都打散順序不彙總只輸出 raw data抹平熱與時段的偏差

圖 10: runner 的工作不是跑得快,而是消除疑慮。

具體例:該準備哪些 bench 項目

有人說「想比較 C# / C++ / Java / Go」時,如果只做一個,建議準備 不容易誤解的單純 CPU 類;如果要做多個,建議準備 性質不同的 workload 3~4 個。

建議的組合

1. sort_int32_10m

目的: 觀察 CPU、記憶體頻寬與暫存區域的使用方式

  • 輸入:以固定 seed 產生的 1,000 萬筆 int32
  • 處理:把陣列 sort 之後回傳 checksum
  • 注意事項:每次都要回到相同的未排序輸入

這個相對容易理解。 但因為也包含標準排序實作的差異,與其說是 語言本身,不如說是 含標準函式庫在內的比較。

2. hash_group_count

目的: 觀察雜湊表、字串處理、配置與 GC 的傾向

  • 輸入:固定的文字資料
  • 處理:計算每個單字的出現次數
  • 輸出:前 N 筆與 checksum

這個接近實務,但相對地,字串函式庫與 map 實作的差異也影響很大。 也因此,比較結果會比較接近現實。

3. parallel_sha256

目的: 觀察平行處理、排程器、worker pool 與同步的習性

  • 輸入:固定大小的二進位 chunk 序列
  • 處理:以 N 個執行緒依序做雜湊,回傳最終的 checksum
  • 條件:把執行緒數分成 1 / 2 / 4 / 8 這樣的階段

比起單純的 tight loop,更容易看出 平行執行時的成長幅度。

4. startup_noop 或 startup_parse_small

目的: 觀察啟動時間

  • noop:啟動後立刻結束
  • parse_small:只處理一次小輸入就結束

在這裡,C# / Java 的 JIT 與初始化成本比較容易顯現,和 C++ / Go 呈現出來的樣子差很多。 反過來說,就算這裡出現差距,也和長時間處理的勝負是兩回事。

性質不同的 4 個 bench 項目說明排序看的是 CPU 與記憶體頻寬,單字計數看的是雜湊、字串與配置,平行雜湊看的是平行執行時的成長幅度,啟動 bench 看的是啟動時間,各自看到的東西都不同,因此準備這 4 個項目的組合的圖。sort_int32_10mCPU、頻寬與暫存區域hash_group_count字串、map 與 GC 的傾向parallel_sha256平行執行時的成長幅度startup 類啟動與初始化的成本

圖 11: 一個項目看不到全部,所以按想看的東西分開設項目。

JSON 與 HTTP 的 bench 該怎麼辦

JSON 與 HTTP 接近實務,當然有意義。 不過那種情況下,與其說是語言比較,不如說是含函式庫、框架與生態系在內的比較。

這件事本身不壞。 在實務上,那一邊反而更重要的情況還不少。 只是在文章或報告中,

這不是語言的比較,而是含標準實作與主要函式庫在內的比較

寫清楚這一點,誤解會比較少。

各語言該對齊的條件

C++

  • 對齊成最佳化建置
  • 固定編譯器
  • 固定標準函式庫實作
  • 寫明 -O3 / /O2、LTO、PGO 等條件
  • 注意結果不要被最佳化消掉
  • 懷疑是不是因為未定義行為才看起來比較快

C++ 自由度高,相對地條件差異也會直接大幅顯現。 因此 是用哪個編譯器、哪些旗標、哪個 STL 測的 相當重要。

C#

  • 對齊成 Release 建置
  • 固定 .NET 的版本
  • 記錄 Server GC / Workstation GC 等條件
  • 寫明有沒有使用 Tiered Compilation、ReadyToRun、Native AOT
  • 分開 cold 與 warm

C# 會因為 .NET 的設定差異而改變呈現的樣子。 特別是 JIT 的 C# 與 Native AOT 的 C#,同樣叫「C#」卻是不同的軸。 把這裡混在一起,比較對象就不是語言而是 散發形態 了。

Java

  • 固定 JDK 的供應商與版本
  • 寫明 GC
  • 固定 warm-up / measurement / fork
  • 記錄堆積大小與 JVM 選項
  • 分開 cold start 與 steady-state

Java 容易得到 JIT 的好處,但相對地第一次執行呈現出來的樣子會差很多。 因此一定要把 短命處理程序的比較 與 長時間運轉的比較 分開。

Go

  • 固定 Go 的版本
  • 固定 GOMAXPROCS
  • 寫明 CGO_ENABLED
  • 若要調整 GOGC 一定要記錄下來
  • 可以的話留下 benchmark 格式的輸出

Go 相對好處理,但在平行 bench 上 GOMAXPROCS 的影響很大。 另外,用不用 cgo 會讓情況完全不同,那一點一定要記進條件裡。

執行環境的對齊方式

不論哪個語言,沒有對齊環境的比較,大多是在比較環境。

沒有對齊環境的比較究竟是什麼說明在 CPU、OS、電源條件、輸入資料、優先權與核心數等環境沒有對齊的比較中,結果的差距會變成環境的差距而不是語言的差距的圖。環境已對齊環境未對齊環境沒有對齊的兩次測量結果出現差距這個差距是什麼的差距可以讀成實作或語言這一側的差距只是在比較環境

圖 12: 只有在消掉環境差異之後,才能把差距歸咎於語言。

該對齊的東西

  • 相同的 CPU / 記憶體 / 儲存裝置
  • 相同的 OS 版本
  • 相同的電源條件
  • 接近相同室溫的條件
  • 相同的輸入資料
  • 相同的處理程序優先權
  • 相同的核心數條件
  • 相同的容器或裸機條件

特別有影響的

電源設定與 CPU 頻率

在筆記型電腦上,光是接 AC 電源還是用電池,就是完全不同的世界。 CPU governor 或 power mode 沒有對齊,比較結果就會相當浮動。

關於 Windows 上電源條件、通知、背景雜訊、熱與執行順序的對齊方式,另一篇文章 在 Windows 上如何比較不同版本程式的執行速度 有詳細的梳理。要在 Windows 上測量的話,這裡相當有影響。

熱

如果只有最初幾次比較快,後半降下來,就懷疑熱或節流(throttling)。 比起把 A 全部跑完再把 B 全部跑完,像 A / B / A / B 這樣交替執行更能減少偏差。

熱的偏差與執行順序說明如果只有最初幾次快,後半降下來,就要懷疑熱或節流,並且不要把 A 全部跑完再跑 B,而是讓 A 與 B 交替執行以減少偏差的圖。只有後半結果變差懷疑熱或節流不要把 A 全部跑完才跑 B讓 A 與 B 交替執行

圖 13: 熱消不掉,但只要在順序上下工夫,就能公平地分配給雙方。

背景處理

更新、索引、同步、病毒掃描、瀏覽器、聊天工具。 這些不起眼,卻很容易剛好撞上。

該測量什麼

在語言比較上,建議至少把這 4 項分開來看。

1. wall-clock time

使用者實際等待的時間。 最先該看的指標就是這個。

2. CPU time

也就是「實際用掉了多少 CPU」。 如果只有 wall-clock 變快而 CPU time 沒變,可能是等待時間或 I/O 的影響。

3. memory / allocations

  • 最大 RSS
  • 總配置量
  • alloc 次數
  • GC 次數
  • GC pause

看這些就能看見速度背後的成本。

4. 分布

  • 中位數
  • p95 / p99
  • min / max
  • 標準差與離散程度

只用平均來談,偶爾暴衝的處理究竟是什麼就看不出來。

分開來看的 4 個指標說明在語言比較上,要把使用者實際等待的 wall-clock、實際用掉的 CPU 時間、最大 RSS 與配置等記憶體面,以及中位數與百分位數等分布這 4 項分開來看的圖。wall-clock time分成 4 項來看CPU time記憶體與配置分布〔中位數與 p95〕也另外看

圖 14: 速度的數字不只一種,要把背後的成本也並列出來才讀得懂。

建議的執行步驟

在維運上比較好執行的流程,大致是這個順序。

否是否是否是1. 決定 workload2. 固定共通資料集3. 先通過正確性檢查所有實作的 checksum是否一致4. 固定 build 條件5. 分開 cold 與 warm6. 隨機化執行順序後執行是否達到需要的次數8. 保存 raw data是否出現有意義的差距記錄條件與次數後結束9. 取得 profile 追究原因

圖 15: 從 workload 的決定、正確性檢查、隨機化的正式測量,到 raw data 保存的執行步驟。

1. 決定 workload

先明確要比較什麼。

  • 啟動時間
  • 穩定狀態的 throughput
  • tail latency
  • 記憶體效率
  • 平行擴展

2. 固定共通資料集

輸入資料以固定 seed 或固定檔案來對齊。 如果連資料的產生都算進去,那也必須在各語言上維持相同條件。

3. 先通過正確性檢查

用小資料與大資料,檢查所有實作都回傳相同的結果。 讓它們輸出 checksum 或雜湊會比較好處理。

4. 固定 build 條件

在各語言建立 Release/已最佳化的執行形式,並記錄版本與旗標。

5. 分開 cold 與 warm

特別是 C# 與 Java,這裡很重要。

  • cold: 包含處理程序啟動直後
  • warm: 執行數次之後的穩定狀態

把測量範圍畫成圖,就能清楚看出這兩者是不同的東西。

以 warm 測量的範圍以 cold 測量的範圍穩定狀態的本體處理執行階段初始化類別載入處理程序啟動本體處理第 1 次JIT 的第一次編譯本體處理第 2 次以後Tiered Compilation 逐步進行

圖 16: cold 包含從處理程序啟動到 JIT 第一次編譯為止,warm 只測量穩定狀態。

C++ 與 Go 既然是事前編譯完成的,就沒有相當於「JIT 第一次編譯」的步驟,執行階段的初始化也相對輕。cold 的差距大多就是在這裡產生的。正因如此,這兩者不要混進同一張表比較乾淨。

6. 讓執行順序交替或隨機化

例:

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

這樣做能減少熱與雜訊的偏差。

7. 確保足夠的次數

輕量的 microbenchmark 要跑相當多次,end-to-end 至少也希望有 10 次以上。 差距很小卻只跑幾次,解讀就會變得相當危險。

8. 保存 raw data

不只保留彙總結果,也要留下 每個 run 的原始資料。 之後再看,就能讀出離群值與 warm-up 的習性。

9. 出現差距就取得 profile

出現差距時,才開始追究原因。

  • CPU profile
  • allocation profile
  • GC 日誌
  • flame graph
  • OS 端的追蹤

做到這一步,就不只是「快/慢」,而是能談 為什麼會這樣。

結果的解讀方式

數字出來之後,解讀方式弄錯一樣很危險。

只有第一次執行時 C# / Java 比較慢

懷疑是 JIT、類別載入、初始化的影響。 這種情況下,

  • 如果啟動時間很重要,這就是有意義的差距
  • 如果主題是長時間運轉,這就是該分到另一張表的差距

就是這兩種。

C++ 在 tight loop 上很強

有可能是低階最佳化、物件佈局、最小的執行階段額外負擔在發揮作用。 但只看那一點就說「所以在實際服務上也最快」,跳得太遠了。

Go 在啟動時間與散發便利性上看起來有利

有時是單一二進位檔、相對輕量的啟動、好用的平行模型在發揮作用。 但這不代表在所有 CPU 類的 workload 上都有利。

C# / Java 在 steady-state 上大幅追上,甚至反超

有可能是 JIT 的最佳化在發揮作用。 這也不是罕見的事。 因此,不要把 含啟動的比較 與 穩定狀態的比較 混在一起,這點很重要。

allocation-heavy 的處理差距很大

這種情況下,比起語言名稱,

  • 記憶體佈局
  • 字串與 map 的處理方式
  • GC 的行為
  • 多餘的複製

這些的影響往往更大。

出現差距時的解讀方式說明如果只有第一次 C# 或 Java 比較慢就懷疑 JIT 與初始化的影響,主題是啟動時間時視為有意義的差距,主題是長時間運轉時視為該分到另一張表的差距,並在出現差距後用 profile 追究原因的圖。含啟動的差距穩定狀態的差距出現差距是哪個條件下的差距主題是啟動時間就有意義當成長時間運轉的比較來處理用 profile 追究原因

圖 17: 在比較數字大小之前,先確認那個差距是哪個場地上的差距。

紀錄範本

bench 結果至少留下這種程度的項目,之後會很有幫助。

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 可以這樣分。

  • micro
  • macro
  • startup
  • parallel

cold_or_warm 一定要明確標示是哪一種。

  • cold
  • warm

每一欄要放什麼,先決定好就不會亂掉。

欄 放入的內容 格式範例
timestamp run 的開始時刻。用來事後查看不同時段造成的變動 ISO 8601 格式。2026-03-17T10:00:00+09:00
language 實作的識別子。為了避免寫法不一致,使用固定的詞彙 cpp / csharp / java / go
scenario bench 項目名稱 sort_int32_10m
run_kind 測量的種類 micro / macro / startup / parallel
cold_or_warm 是否包含啟動 cold / warm
elapsed_ms wall-clock。留到小數點後第 3 位,之後就不會有困擾 小數的毫秒
cpu_ms 處理程序的 CPU 時間。user 與 system 相加的值 小數的毫秒
max_rss_mb 最大 RSS 整數或小數的 MB
alloc_bytes 總配置位元組數。取不到的語言就留空,並把留空這件事本身記錄下來 整數,或留空
gc_count GC 次數。C++ 一律留空 整數,或留空
checksum 正確性檢查用。另外檢查所有實作是否一致 16 進位的字串
compiler_or_runtime 編譯器或執行階段的種類 msvc / dotnet / temurin / go
compiler_version 編譯器或執行階段的版本。連 minor 版號都寫上 該編譯器或執行階段輸出的版本字串
flags 最佳化條件。/O2、-O3 -flto、Server GC、GOMAXPROCS=8 等 以空白分隔的字串
os / cpu / threads 執行環境 OS 名稱與組建編號、CPU 型號、使用的執行緒數
input_id 資料集的識別子。用檔案的雜湊值最保險 檔案名稱與雜湊值
notes 有異常的 run 的備註 自由填寫

重點是 允許測量值的欄位留空,並且把留空這件事本身記錄下來。「因為是 C++ 所以沒有 gc_count」是一項資訊,但整欄刪掉之後就看不出來了。

只看平均會漏掉什麼

彙總時只收成一個平均值,資訊就會不見。下面是 為了說明算術而虛構的數字,不是任何語言的實測值。假設它是某個實作的 10 個 run 依快慢排序後的 elapsed_ms。

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

從這 10 個值算出來的代表值如下。

指標 值 解讀方式
平均 163.4 被最後那一次拖著走,比實際的常用區間高出約 6 成
中位數 101.5 接近 10 次中 9 次的實際感受
min / max 98 / 720 差距超過 7 倍,是先去查離群值原因的訊號
p95 / p99 算不出來 只有 10 個樣本時,95 百分位數與 99 百分位數都沒有意義

也就是說,只放平均的表格,會讓「偶爾大幅停頓的實作」和「穩定但稍慢的實作」看起來一樣。就表格的做法而言,下面這些差別很有影響。

觀點 不好用的結果表 能用的結果表
代表值 只有平均 以中位數為主,並列出 min / max 與離散程度
試驗次數 沒有寫 寫明 run 數,以及有沒有排除離群值的方針
cold / warm 混在一起,或沒有區分 分成不同的表,或分成不同的列
正確性 沒有提到 寫明所有實作的 checksum 都一致
條件 「在同一台電腦上測的」 連 OS、CPU、編譯器或執行階段的版本、最佳化旗標、執行緒數都寫上
原始資料 只有彙總值 一併寫上 raw CSV 的保存位置

想放 p95 或 p99,那本來就得增加 run 數。這只是 要談分布,就需要足以看見分布的樣本數 而已。

bench 有時候 事後能不能解讀 比 測量本身 更重要。

單一平均值藏住的東西說明只要有一次很大的離群值,平均就會大幅偏離常用區間,中位數反而比較接近實際感受,而 min 與 max 的差距很大時就是去查離群值原因的訊號,因此只有平均的表格很危險的圖。只出現一次很大的離群值平均往常用區間之上偏移只有平均的表格會藏住實際情況一併列出中位數與 min 和 max離群值是查原因的訊號

圖 18: 平均不會說謊,但它對離群值的存在保持沉默。

總結

C# / C++ / Java / Go 的速度比較,真正重要的是 把 哪個語言最快 這個粗略的問題, 落成 要比較哪個 workload、在哪個條件下、用哪個指標 這樣的實驗形式。

特別不容易出錯的,是這幾點。

  • 分開啟動時間與穩定狀態
  • 用同樣的演算法、同樣的輸入、同樣的正確性檢查來測
  • 不要只用一個 bench 就下結論
  • 分開語言內的 benchmark 與跨語言的 benchmark
  • 比起平均,更要看中位數與分布
  • 留下條件與 raw data

最後最重要的是,不要太想用語言名稱來決定勝負。 真實的效能是由語言、執行階段、函式庫、建置條件、資料、OS、硬體共同決定的。

「C++ 快」「Java 強」「Go 輕」「C# 也夠快」這些說法,在某種意義上全都對。 但只要少了 是在哪個條件下這樣說的,討論就會兜不攏地結束。

對齊條件,用多個 workload,分開 cold / warm,一路看到分布。 不起眼,但結果這才是最強的。

把粗略的問題落成實驗的形式說明把哪個語言最快這個粗略的問題,落成要比較哪個 workload、在哪個條件下、用哪個指標的實驗形式,並且對齊條件、用多個 workload、分開 cold 與 warm、一路看到分布才是最強做法的圖。哪個語言最快重新落成實驗的形式決定 workload、條件與指標分開 cold 與 warm 執行看到分布並連同條件一起留下

圖 19: 重新設定問題本身,才是本文最大的結論。

參考資料

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

  • BenchmarkDotNet Setup and Cleanup(可以使用 [IterationSetup] 的條件) https://benchmarkdotnet.org/articles/features/setup-and-cleanup.html

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

  • JMH Level javadoc(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

  • 在 Windows 上如何比較不同版本程式的執行速度 https://comcomponent.com/zh-TW/blog/2026/03/16/002-windows-benchmark-comparing-program-versions/

相關主題

與本文一起閱讀會比較好理解的頁面。

這個主題的諮詢窗口

效能比較的設計、測量條件的對齊方式、結果的解讀、原因的深入追查,都很適合搭配下列服務。

共用相同標籤的最新文章。能以相近的主題延伸理解。

與本文相近的主題頁面。以本文為起點,可進一步連到相關服務與其他文章。

本文連結到以下服務頁面,歡迎從最接近的入口查看。

常見問題

整理諮詢這個主題時常見的問題。

C#、C++、Java、Go 之中哪一個最快?
沒辦法用一個數字決定。真實的效能是由語言、執行階段、函式庫、建置條件、資料、OS、硬體共同決定的。重點是把「哪個語言最快」這個問題,落成「要比較哪個 workload、在哪個條件下、用哪個指標」的實驗形式。在啟動時間上,C# 與 Java 的 JIT 與初始化成本比較容易顯現;但在 steady-state 上,JIT 的最佳化開始發揮作用,追上甚至反超也不算罕見。
為什麼 C# 與 Java 的 benchmark 要把 warm-up 分開?
因為 C# 與 Java 通常會受 JIT 影響,測量第一次執行時,除了程式本體的速度,也會把執行階段的啟動、類別載入、JIT 準備一起測進去。C++ 與 Go 則通常是事前編譯完成的。cold 與 warm 都有意義,但意義並不相同,所以包含處理程序啟動直後的 cold,與執行數次之後的穩定狀態 warm,要當成不同的東西處理,不要混進同一張表比較乾淨。
跨語言的 benchmark 該怎麼設計?
建議採用 2 層構成。語言內的測量使用適合該語言的 harness:C# 用 BenchmarkDotNet、Java 用 JMH、Go 用 go test -bench 與 benchstat、C++ 用 Google Benchmark。跨語言比較時,直接把各 harness 的結果並排很危險,比較合理的做法是把每個實作都做成能以相同 CLI 合約呼叫的執行檔,再由外側的共通 runner 負責隨機化執行順序、分開 cold 與 warm、使用同一份資料集、驗證 checksum、保存 raw data。
C++ 的 microbenchmark 要注意什麼?
要小心最佳化把處理消掉的陷阱。編譯器一旦判斷「這個計算結果沒有人在用」,就會把處理本身消掉,測到的不是快,而是根本什麼都沒做的結果。因此使用結果、輸出 checksum,以及 benchmark 框架的最佳化抑制功能都很重要。另外 C++ 的條件差異會直接大幅顯現,所以寫清楚是用哪個編譯器、哪些旗標(-O3/O2、LTO、PGO 等)、哪個 STL 測的,相當重要。

作者檔案

本文作者的個人檔案頁面。

Go Komura

小村軟體有限公司 代表

以 Windows 軟體開發、技術諮詢與故障調查為中心,在難以重現的故障調查與既有資產仍在運作的專案上具有優勢。

回到部落格一覽