更新紀錄(2 筆,最後更新 2026年09月04日)
本文的修改紀錄。已保存的更新前版本,可透過附有 DOI 的永久連結閱讀。
引用本文(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 的有無與特性也不一樣。標準函式庫與周邊函式庫的實作差異也相當有影響。而且就算是同一台機器,電源設定、熱、背景處理、輸入資料的偏差都會讓結果輕易浮動。這是個相當瑣碎麻煩的世界。
flowchart TB
accTitle: 結果浮動的成因
accDescr: 說明 JIT 與 warm-up 的影響、GC 的有無與特性差異、標準函式庫與周邊函式庫的實作差異,加上電源設定、熱、背景處理、輸入資料的偏差疊在一起,測量結果就會輕易浮動的圖。
n1["JIT 與 warm-up 的影響"] --> n4["結果會輕易浮動"]
n2["GC 與函式庫的實作差異"] --> n4
n3["電源、熱、雜訊、輸入偏差"] --> n4
n4 -.-> n5["不要並排不同環境的數字來決定優劣"]
圖 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 件事。
-
先決定要比較的是什麼的速度 要看的是啟動時間、穩定狀態的 throughput、p95 延遲,還是記憶體效率,測量方式都不一樣。
-
不要只用一個 bench 就下結論 在 CPU 計算、記憶體配置、平行處理、啟動時間上,哪個語言或執行階段看起來強會不一樣。
-
C# 與 Java 要把 cold 和 warm 分開 把包含第一次執行的比較,和 warm-up 之後的穩定狀態比較混在一起,討論就會走樣。
-
用同樣的演算法、同樣的輸入、同樣的正確性檢查來測 不是實作比較快,而是根本在解不同的問題,這是 bench 很常見的狀況。
-
把語言內的 microbenchmark 和跨語言的 end-to-end bench 分開 各語言的專用 harness 很方便,但跨語言的比較交給外側的共通 runner 來跑,方向比較對。
-
不只看平均,也要看中位數與分布 只要有一次剛好撞上 GC 或背景處理,平均就會壞掉。
-
不只留下數字,也要留下條件 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、在哪個條件下、以哪個指標來看能處理得比較快
這才是真正該問的問題。
把這裡放著曖昧不清就開始收集數字,最後會收不攏。
flowchart TB
accTitle: 先決定要把什麼叫做快
accDescr: 說明想看的是啟動時間、穩定狀態的 throughput、尾端延遲還是記憶體效率,測量方式都會不同,所以比較之前要先決定看哪個 workload、在哪個條件下、用哪個指標的圖。
q1["這個比較想知道的是什麼"] --> a1["啟動時間"]
q1 --> a2["穩定狀態的 throughput"]
q1 --> a3["p95 / p99 的延遲"]
a3 -.-> a4["記憶體效率是另一條軸"]
a1 -.-> a5["每條軸的測量方式都不同"]
圖 2: 在「快」的定義決定之前,不要開始收集數字。
為什麼語言比較很困難
混進 JIT 與 AOT,就變成不同的實驗
C# 與 Java 通常會受到 JIT 影響。 另一方面,C++ 與 Go 通常是事前編譯完成的。
也就是說,測量第一次執行時,除了 程式本體的速度,也會把 執行階段的啟動、類別載入、JIT 準備 一起測進去。 反過來,如果只看充分 warm-up 之後,那就變成 穩定狀態下最佳化能發揮到什麼程度 的比較。
兩者都有意義。 但是,意義並不相同。
flowchart TB
accTitle: JIT 組與 AOT 組的差別
accDescr: 說明 C# 與 Java 通常受 JIT 影響,第一次執行會混進執行階段啟動、類別載入與 JIT 準備,而 C++ 與 Go 通常是事前編譯完成的,因此測量第一次的比較與測量 warm-up 之後的比較意義並不相同的圖。
j1["C# 與 Java〔通常 JIT〕"] --> j2["第一次執行會混進啟動與 JIT 準備"]
g1["C++ 與 Go〔通常 AOT〕"] --> g2["以事前編譯完成的形式執行"]
j2 --> mix["第一次的比較與穩定狀態的比較是不同的實驗"]
g2 --> mix
圖 3: 執行模型不同的語言之間,從哪裡開始測就決定了實驗的內容。
實作差異大於語言差異,是很常見的事
同樣是「排序」,
- 一邊用標準函式庫
- 一邊自己實作
- 一邊多做了一次複製
- 一邊每次都重新產生輸入
光是這些,結果就會差很多。
再者,一旦處理變成 JSON、壓縮、加密、正規表示式這一類,函式庫實作 的差異就會比 語言本身 更有影響。 所以不寫清楚在測什麼,本來要做的「語言比較」就會變成「函式庫比較」。
flowchart TB
accTitle: 語言比較被偷換成函式庫比較
accDescr: 說明同樣的處理會因為用標準函式庫還是自己實作、有沒有多餘的複製或每次重新產生輸入而結果不同,而在 JSON、壓縮、加密、正規表示式上,函式庫實作的差異比語言本身更有影響,所以不寫清楚在測什麼,本來要做的語言比較就會變成函式庫比較的圖。
d1["理應做同樣處理的各個實作"] --> d2["實作與函式庫的差異混在一起"]
d2 --> d3{"有沒有寫清楚在測什麼"}
d3 -->|"沒有"| d4["本來要做語言比較卻變成函式庫比較"]
d3 -->|"有"| d5["可以當成比較來解讀"]
圖 4: 只要把測量對象的名字取對,大半的誤解就會消失。
C++ 有「最佳化把處理消掉」的陷阱
特別是在 microbenchmark 中,編譯器一旦判斷「這個計算結果沒有人在用」,就可能把處理消掉。 這樣一來,測到的不是快,而是 根本什麼都沒做 的結果。典型的樣子是整個迴圈都消失,執行時間幾乎歸零。
在 C++ 上這個問題特別容易明顯地出現,所以使用結果、輸出 checksum,或是 benchmark 框架的最佳化抑制功能都相當重要。
flowchart TB
accTitle: 最佳化把處理消掉的陷阱
accDescr: 說明在 microbenchmark 中,只要編譯器判斷沒有人使用計算結果就會把處理本身消掉,測到的不是快而是什麼都沒做的結果,因此使用結果、輸出 checksum 與最佳化抑制功能變得重要的圖。
o1["沒有人使用計算結果"] --> o2["編譯器把處理消掉"]
o2 --> o3["執行時間看起來幾乎為零"]
o3 -.-> o4["不是快,而是什麼都沒做"]
o3 --> o5["以 checksum 輸出與抑制功能防止"]
圖 5: 快得離譜的結果,先懷疑是不是已經被消掉了。
GC 的存在既不是「不利」也不是「有利」,而是特性
C#、Java、Go 都有 GC。 把它簡化成「有 GC 所以慢」太粗略了。
實際上,
- 如何處理大量的短命物件
- 堆積大小的設定
- GC 的頻率與 pause
- 物件佈局
- 函式庫的配置習性
這些的影響更大。
反過來說,C++ 可以用手動管理或 RAII 做細緻的控制,但相對地設計與實作的差異也容易顯現。 也就是說,管理方式的不同,並不直接等於好壞或優劣。
flowchart TB
accTitle: GC 不是不利,而是特性
accDescr: 說明在有 GC 的語言上,短命物件的處理方式、堆積設定、GC 的頻率與 pause、配置習性的影響更大,而 C++ 因為能細緻控制,設計與實作的差異也容易顯現,因此管理方式的不同並不直接等於優劣的圖。
gc1["有 GC 的語言"] --> gc2["設定與配置習性的影響較大"]
gp1["C++ 的手動管理與 RAII"] --> gp2["可以控制但容易出現實作差異"]
gc2 --> gv1["管理方式的不同不等於優劣"]
gp2 --> gv1
圖 6: 為了不要用「有 GC 所以慢」一句話帶過而做的梳理。
比較時不能做的事
1. 混用 Debug 與 Release
這根本不用討論。 比較對象一定要對齊成 等同正式環境的最佳化建置。
2. 沒有在解同一個問題
輸入格式不同,輸出不同,只有一邊沒有錯誤處理,記憶體重複使用的方針不同。 放著這些不管,測到的就會是 需求的差異而不是速度。
3. 只執行一次就下結論
只執行一次,大多是雜訊。
- JIT
- 分頁快取
- CPU 的頻率提升
- 熱
- 背景工作
- GC
- 第一次的檔案讀取
這些會在同一次執行裡全部混在一起。
flowchart TB
accTitle: 只執行一次會混進來的東西
accDescr: 說明只執行一次會把 JIT、分頁快取、CPU 的頻率提升、熱、背景工作、GC、第一次的檔案讀取全部混在一起,因此一次的結果大多是雜訊的圖。
x1["JIT 與第一次的讀取"] --> x4["全部混進同一次執行"]
x2["快取與 CPU 頻率提升"] --> x4
x3["熱與背景處理"] --> x4
x4 --> x5["只跑一次的結果大多是雜訊"]
圖 7: 只有一次的數字,裡面混進最多不想測的東西。
4. 讓 warm-up 混在一起
測量 C# 與 Java 時,要不要包含第一次執行,還是只看 warm-up 之後,這裡一曖昧,討論就會垮掉。 要把 cold 與 warm 當成不同的東西 來處理。
5. 不做正確性檢查
bench 在「快」之前,必須先做到「回傳相同的結果」。 一定要檢查比較對象的所有實作,都能從相同的輸入得到相同的 checksum 或相同的輸出。
6. 只用一個 microbenchmark 就決定整個世界觀
只在 tight loop 上贏,不代表在整個實際服務上也會贏。 反過來說,就算啟動時間輸了,長時間運轉時也可能夠強。
比較 C# / C++ / Java / Go 時的基本方針
這裡相當重要。 建議採用 2 層構成。
flowchart TB
subgraph outer["外層:跨語言的共通 runner"]
direction TB
R["共通 runner<br/>執行順序隨機化 / 分開 cold 與 warm<br/>checksum 驗證 / 保存 raw data"]
R --> E1["bench 執行檔<br/>C++"]
R --> E2["bench 執行檔<br/>C#"]
R --> E3["bench 執行檔<br/>Java"]
R --> E4["bench 執行檔<br/>Go"]
end
subgraph inner["內層:語言內的深入探討"]
direction TB
H1["Google Benchmark"]
H2["BenchmarkDotNet"]
H3["JMH"]
H4["go test -bench 與 benchstat"]
end
E1 -.->|"在語言內詳細測量同樣的處理"| H1
E2 -.->|"在語言內詳細測量同樣的處理"| H2
E3 -.->|"在語言內詳細測量同樣的處理"| H3
E4 -.->|"在語言內詳細測量同樣的處理"| H4
圖 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,所以 含啟動的時間 與 只有本體的時間 兩者都會留下來。
flowchart TB
accTitle: CLI 合約與輸出合約
accDescr: 說明把各語言的實作做成能以相同 CLI 合約呼叫的執行檔,並約定只往標準輸出印出 checksum 與 inner_ms 兩行,而 runner 另外測量整個處理程序的時間,因此含啟動的時間與只有本體的時間都會留下來的圖。
ct1["相同 CLI 合約的執行檔"] --> ct2["印出 checksum 與 inner_ms 兩行"]
ct2 --> ct3["runner 測量整體的 wall-clock"]
ct3 --> ct4["含啟動與只有本體的時間都留下來"]
圖 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 計算
這麼做之後,就比較容易把 各語言內部的最佳實踐 與 跨語言的公平性 分開處理。
flowchart TB
accTitle: 共通 runner 的 3 個意圖
accDescr: 說明共通 runner 會刻意做 3 件事:把以 checksum 進行的正確性檢查放在速度測量之前、每個 run 都打散執行順序、不做彙總只輸出 raw data 的圖。
rn1["正確性檢查放在速度之前"] --> rn4["能公平比較的測量"]
rn2["每個 run 都打散順序"] --> rn4
rn3["不彙總只輸出 raw data"] --> rn4
rn2 -.-> rn5["抹平熱與時段的偏差"]
圖 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 呈現出來的樣子差很多。 反過來說,就算這裡出現差距,也和長時間處理的勝負是兩回事。
flowchart TB
accTitle: 性質不同的 4 個 bench 項目
accDescr: 說明排序看的是 CPU 與記憶體頻寬,單字計數看的是雜湊、字串與配置,平行雜湊看的是平行執行時的成長幅度,啟動 bench 看的是啟動時間,各自看到的東西都不同,因此準備這 4 個項目的組合的圖。
w1["sort_int32_10m"] -.-> v1["CPU、頻寬與暫存區域"]
w2["hash_group_count"] -.-> v2["字串、map 與 GC 的傾向"]
w3["parallel_sha256"] -.-> v3["平行執行時的成長幅度"]
w4["startup 類"] -.-> v4["啟動與初始化的成本"]
w1 --> w2
w2 --> w3
w3 --> w4
圖 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 會讓情況完全不同,那一點一定要記進條件裡。
執行環境的對齊方式
不論哪個語言,沒有對齊環境的比較,大多是在比較環境。
flowchart TB
accTitle: 沒有對齊環境的比較究竟是什麼
accDescr: 說明在 CPU、OS、電源條件、輸入資料、優先權與核心數等環境沒有對齊的比較中,結果的差距會變成環境的差距而不是語言的差距的圖。
en1["環境沒有對齊的兩次測量"] --> en2["結果出現差距"]
en2 --> en3{"這個差距是什麼的差距"}
en3 -->|"環境已對齊"| en4["可以讀成實作或語言這一側的差距"]
en3 -->|"環境未對齊"| en5["只是在比較環境"]
圖 12: 只有在消掉環境差異之後,才能把差距歸咎於語言。
該對齊的東西
- 相同的 CPU / 記憶體 / 儲存裝置
- 相同的 OS 版本
- 相同的電源條件
- 接近相同室溫的條件
- 相同的輸入資料
- 相同的處理程序優先權
- 相同的核心數條件
- 相同的容器或裸機條件
特別有影響的
電源設定與 CPU 頻率
在筆記型電腦上,光是接 AC 電源還是用電池,就是完全不同的世界。 CPU governor 或 power mode 沒有對齊,比較結果就會相當浮動。
關於 Windows 上電源條件、通知、背景雜訊、熱與執行順序的對齊方式,另一篇文章 在 Windows 上如何比較不同版本程式的執行速度 有詳細的梳理。要在 Windows 上測量的話,這裡相當有影響。
熱
如果只有最初幾次比較快,後半降下來,就懷疑熱或節流(throttling)。 比起把 A 全部跑完再把 B 全部跑完,像 A / B / A / B 這樣交替執行更能減少偏差。
flowchart TB
accTitle: 熱的偏差與執行順序
accDescr: 說明如果只有最初幾次快,後半降下來,就要懷疑熱或節流,並且不要把 A 全部跑完再跑 B,而是讓 A 與 B 交替執行以減少偏差的圖。
th1["只有後半結果變差"] --> th2["懷疑熱或節流"]
th2 --> th3["不要把 A 全部跑完才跑 B"]
th3 --> th4["讓 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
- 標準差與離散程度
只用平均來談,偶爾暴衝的處理究竟是什麼就看不出來。
flowchart TB
accTitle: 分開來看的 4 個指標
accDescr: 說明在語言比較上,要把使用者實際等待的 wall-clock、實際用掉的 CPU 時間、最大 RSS 與配置等記憶體面,以及中位數與百分位數等分布這 4 項分開來看的圖。
ms1["wall-clock time"] --> ms5["分成 4 項來看"]
ms2["CPU time"] --> ms5
ms3["記憶體與配置"] --> ms5
ms5 -.-> ms4["分布〔中位數與 p95〕也另外看"]
圖 14: 速度的數字不只一種,要把背後的成本也並列出來才讀得懂。
建議的執行步驟
在維運上比較好執行的流程,大致是這個順序。
flowchart TB
a1["1. 決定 workload"] --> a2["2. 固定共通資料集"]
a2 --> a3["3. 先通過正確性檢查"]
a3 --> a4{"所有實作的 checksum<br/>是否一致"}
a4 -- 否 --> a3
a4 -- 是 --> a5["4. 固定 build 條件"]
a5 --> a6["5. 分開 cold 與 warm"]
a6 --> a7["6. 隨機化執行順序後執行"]
a7 --> a8{"是否達到<br/>需要的次數"}
a8 -- 否 --> a7
a8 -- 是 --> a9["8. 保存 raw data"]
a9 --> a10{"是否出現<br/>有意義的差距"}
a10 -- 否 --> a11["記錄條件與次數後結束"]
a10 -- 是 --> a12["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: 執行數次之後的穩定狀態
把測量範圍畫成圖,就能清楚看出這兩者是不同的東西。
flowchart LR
subgraph coldrange["以 cold 測量的範圍"]
direction LR
s1["處理程序啟動"] --> s2["執行階段初始化<br/>類別載入"]
s2 --> s3["JIT 的第一次編譯"] --> s4["本體處理第 1 次"]
end
s4 --> s5["本體處理第 2 次以後<br/>Tiered Compilation 逐步進行"]
subgraph warmrange["以 warm 測量的範圍"]
direction LR
s6["穩定狀態的本體處理"]
end
s5 --> s6
圖 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 的行為
- 多餘的複製
這些的影響往往更大。
flowchart TB
accTitle: 出現差距時的解讀方式
accDescr: 說明如果只有第一次 C# 或 Java 比較慢就懷疑 JIT 與初始化的影響,主題是啟動時間時視為有意義的差距,主題是長時間運轉時視為該分到另一張表的差距,並在出現差距後用 profile 追究原因的圖。
rd1["出現差距"] --> rd2{"是哪個條件下的差距"}
rd2 -->|"含啟動的差距"| rd3["主題是啟動時間就有意義"]
rd2 -->|"穩定狀態的差距"| rd4["當成長時間運轉的比較來處理"]
rd3 --> rd5["用 profile 追究原因"]
rd4 --> rd5
圖 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 可以這樣分。
micromacrostartupparallel
cold_or_warm 一定要明確標示是哪一種。
coldwarm
每一欄要放什麼,先決定好就不會亂掉。
| 欄 | 放入的內容 | 格式範例 |
|---|---|---|
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 有時候 事後能不能解讀 比 測量本身 更重要。
flowchart TB
accTitle: 單一平均值藏住的東西
accDescr: 說明只要有一次很大的離群值,平均就會大幅偏離常用區間,中位數反而比較接近實際感受,而 min 與 max 的差距很大時就是去查離群值原因的訊號,因此只有平均的表格很危險的圖。
av1["只出現一次很大的離群值"] --> av2["平均往常用區間之上偏移"]
av2 --> av3["只有平均的表格會藏住實際情況"]
av3 --> av4["一併列出中位數與 min 和 max"]
av4 -.-> av5["離群值是查原因的訊號"]
圖 18: 平均不會說謊,但它對離群值的存在保持沉默。
總結
C# / C++ / Java / Go 的速度比較,真正重要的是 把 哪個語言最快 這個粗略的問題, 落成 要比較哪個 workload、在哪個條件下、用哪個指標 這樣的實驗形式。
特別不容易出錯的,是這幾點。
- 分開啟動時間與穩定狀態
- 用同樣的演算法、同樣的輸入、同樣的正確性檢查來測
- 不要只用一個 bench 就下結論
- 分開語言內的 benchmark 與跨語言的 benchmark
- 比起平均,更要看中位數與分布
- 留下條件與 raw data
最後最重要的是,不要太想用語言名稱來決定勝負。 真實的效能是由語言、執行階段、函式庫、建置條件、資料、OS、硬體共同決定的。
「C++ 快」「Java 強」「Go 輕」「C# 也夠快」這些說法,在某種意義上全都對。 但只要少了 是在哪個條件下這樣說的,討論就會兜不攏地結束。
對齊條件,用多個 workload,分開 cold / warm,一路看到分布。 不起眼,但結果這才是最強的。
flowchart TB
accTitle: 把粗略的問題落成實驗的形式
accDescr: 說明把哪個語言最快這個粗略的問題,落成要比較哪個 workload、在哪個條件下、用哪個指標的實驗形式,並且對齊條件、用多個 workload、分開 cold 與 warm、一路看到分布才是最強做法的圖。
sq1["哪個語言最快"] --> sq2["重新落成實驗的形式"]
sq2 --> sq3["決定 workload、條件與指標"]
sq3 --> sq4["分開 cold 與 warm 執行"]
sq4 --> sq5["看到分布並連同條件一起留下"]
圖 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
Leveljavadoc(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
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
-
在 Windows 上如何比較不同版本程式的執行速度 https://comcomponent.com/zh-TW/blog/2026/03/16/002-windows-benchmark-comparing-program-versions/
相關主題
與本文一起閱讀會比較好理解的頁面。
這個主題的諮詢窗口
效能比較的設計、測量條件的對齊方式、結果的解讀、原因的深入追查,都很適合搭配下列服務。
相關文章
共用相同標籤的最新文章。能以相近的主題延伸理解。
引數為什麼會壞掉 ── Windows 命令列引數的規則
在 Windows 上並不存在引數的陣列,傳給 CreateProcess 的是一條字串,分割由接收端負責。本文說明 CommandLineToArgvW・CRT・.NET 的分割規則,以及 .NET 的 ArgumentList 與 C++ 中正確的組裝方式。
父處理程序消失之後還剩下什麼 —— 用 Job Object 圈養子處理程序
為什麼強制結束 UI 之後,SDK 的輔助處理程序仍然殘留,一直佔著攝影機或 COM 連接埠?本文從量測應用的角度,說明如何用 Job Object 把處理程序樹變成一個單位,並借助 KillOnJobClose 與完成埠來設計子處理程序的壽命。
具名管道實務 ── 從設計到安全,看懂 Windows 行程間通訊的標準做法
以實務角度說明 Windows 行程間通訊的標準做法——具名管道。依據一手資料整理位元組模式與訊息模式的取捨、同時接受多個用戶端的伺服器結構、ACL 與模擬的安全設計,以及 .NET 的具名管道串流。
虛假喚醒 ── 條件變數為何「沒被通知也會醒來」,以及 Windows 上正確的等待方式
條件變數的 wait 即使沒有通知到來也可能醒來(虛假喚醒)。本文從 Windows 的實作解開規格為何允許它,並用 Win32、C++ 與 C# 的程式碼示範以 while 與述詞撰寫的正確等待方式。
DLL・COM 介面的向後相容性 ── 哪些變更會破壞呼叫端的判斷表
DLL 或 COM 元件的哪些變更會破壞呼叫端?本文整理二進位相容・原始碼相容・行為相容三層,依變更內容列出判斷表、COM 介面不可變的鐵則,以及 semver 的運用方式,作為實務指南彙整。
相關主題
與本文相近的主題頁面。以本文為起點,可進一步連到相關服務與其他文章。
Windows 技術主題
彙整 KomuraSoft LLC 關於 Windows 開發、故障調查與既有資產活用文章的主題中心。
與本主題相關的服務
本文連結到以下服務頁面,歡迎從最接近的入口查看。
技術諮詢 & 設計審查
從效能比較的設計、測量條件的對齊方式,到 warm-up 與統計的解讀方式,都很適合以技術諮詢、設計審查的形式一起討論。
故障調查 & 根本原因分析
跨語言、跨版本的效能差異要釐清原因,找出瓶頸,並檢查測量步驟是否妥當,這些都適合以缺陷調查、原因解析的方式推進。
常見問題
整理諮詢這個主題時常見的問題。
- 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 測的,相當重要。