更新履歴(6件・最終更新 2026年08月02日)
この記事に加えた変更の記録です。アーカイブした更新前のバージョンは、DOI付きの固定URLから読めます。
- 記事の冒頭に「この記事の知識マップ」節を追加しました。本文で扱っている概念とその関係を、要約・図・詳細ページへのリンクにまとめたものです。本文の主張は変えていません。
- 外部レビュー(1283件)への対応として本文を更新しました。個々の変更内容は、この下の履歴を参照してください。
- 計測スクリプトが、親ディレクトリを持たない出力パス(`-OutCsv raw.csv`)で倒れていました。`Split-Path -Parent`が空文字を返し、`New-Item`がそれを拒否します。倒れるのは全runが終わったあとなので、測定結果を丸ごと失います。親が空でないときだけディレクトリを作るようにしました。
- 実装が`inner_ms`を出し忘れても、終了コード0でchecksumさえ返っていれば通ってしまい、`NaN`のままCSVに入っていたのを直しました。内側の時間の比較が黙って壊れるため、値が無い場合と不正な場合を弾くようにしました。
- 共通ランナーの本番ループでcheckksumを照合していなかったのを直しました。1の確認は捨てるwarm runに対するものなので、記録するrunも1本ずつ照合しないと、途中から違う仕事をするようになった実装の時間がCSVに混ざります。
- 外側の共通ランナーと内側の言語別ハーネスという2層構成、実行手順、coldとwarmの測定範囲の3つの図を追加しました。共通ランナーの骨格をPowerShellで、最小のハーネスをBenchmarkDotNetとJMHで示し、用語表と記録テンプレートの記入方法、平均だけを見ると何を見落とすかの説明を加えました。比喩で書いていた3か所を具体的な記述に直しました。
- 初版公開
この記事を引用する(DOI: 10.5281/zenodo.21589682)
この記事はZenodoにアーカイブされています。常に最新版へ解決されるDOIと、いま表示している版に固定されたDOIの両方を下に示します。
小村 豪(2026)「C#/C++/Java/Goの実行速度を公平に比較する方法」合同会社小村ソフト. https://doi.org/10.5281/zenodo.21589682 https://staging.comcomponent.com/blog/2026/03/17/000-language-benchmark-csharp-cpp-java-go/
- DOI(最新版)
- 10.5281/zenodo.21589682
- DOI(この版)
- 10.5281/zenodo.21732706
「C++ は速いらしい」 「Go は実運用で軽い」 「Java は長時間回すとかなり速い」 「C# も .NET の JIT があるから意外と強い」
この手の話はよく出ます。 ただ、ここで一番やってはいけないのは、別々の人が別々の環境で測った数字を並べて、そのまま言語の優劣だと決めることです。
C# と Java は JIT や warm-up の影響を受けやすく、C++ と Go は通常は事前コンパイル済みです。 GC の有無や特性も違います。標準ライブラリや周辺ライブラリの実装差もかなり効きます。さらに、同じマシンでも電源設定、熱、バックグラウンド処理、入力データの偏りで結果は簡単にぶれます。なかなか泥くさい世界です。
この記事では、C# / C++ / Java / Go をできるだけ公平に比較するための測り方を整理します。 結論だけ先に言うと、「どの言語が最速か」を 1 本の数字で決めようとしないことがいちばん大事です。
この記事は、あくまで比較方法の整理が主題です。 環境依存の数字を並べても、条件が違えば簡単に逆転してしまいます。そのため、ここでは実測ランキングは書きません。その代わり、どう設計すれば比較に価値が出るかに絞って整理します。
対象読者
複数言語の候補があって、どれで実装するかを性能面から判断したい開発者・技術リーダー と、社内やレポートで「速度を比較しました」と言える形の測定を組み立てたい方 に向けて書いています。特定言語の深掘りではなく、4 言語をまたいだ比較の設計が主題なので、どれか 1 つの言語しか触らない方でも読める内容にしています。
コード例は、共通ランナーが PowerShell、言語内ハーネスが 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) | 一度実行して集めた分岐や呼び出しのプロファイルを、次のビルドの最適化判断に使う仕組みです |
| Tiered Compilation | .NET の JIT が、まず素早くコンパイルできるコードで動かし、よく呼ばれるメソッドだけ後から最適化し直す仕組みです。cold と warm の差が出る主因の 1 つです |
| Server GC / Workstation GC | .NET の GC 動作モードです。Server GC は論理プロセッサごとにヒープと GC 用スレッドを持ちスループット寄り、Workstation GC は応答性寄りに振ります |
| GOMAXPROCS | Go のランタイムが Go のコードを同時実行する OS スレッド数の上限です。並列ベンチではここを固定しないと結果を比較できません |
| cgo | Go から C のコードを呼ぶ仕組みです。有効にすると呼び出しコスト、ビルド条件、静的リンクの可否が変わります |
まず結論
C# / C++ / Java / Go の速度比較で本当に効くのは、この 7 つです。
-
何の速さを比べたいのかを先に決める 起動時間なのか、定常状態の throughput なのか、p95 遅延なのか、メモリ効率なのかで、測り方が変わります。
-
1 本のベンチだけで結論を出さない CPU 計算、メモリ割り当て、並列処理、起動時間では、強い言語やランタイムの見え方が変わります。
-
C# と Java は cold と warm を分ける 初回実行を含む比較と、warm-up 後の定常状態比較を混ぜると、話がねじれます。
-
同じアルゴリズム、同じ入力、同じ正しさ確認で測る 速い実装ではなく、別の問題を解いていただけ、はベンチあるあるです。
-
言語内の microbenchmark と、言語横断の end-to-end ベンチを分ける 各言語の専用ハーネスは便利ですが、言語をまたぐ比較は外側の共通ランナーで回すほうが筋がよいです。
-
平均だけでなく中央値と分布を見る 1 回だけ GC やバックグラウンド処理が刺さるだけで平均は壊れます。
-
数字だけでなく条件を残す ベンチ結果は速さの記録であると同時に、実験条件の記録です。条件が書かれていない結果は、後からかなりつらいです。
この記事の知識マップ
この記事はC#・C++・Java・Goの実行速度を1本の数字で比較することの危うさを指摘し、公平な比較のための測定設計を整理している。C#とJavaはJITコンパイルとTiered Compilationの影響でcoldとwarmの差が大きく、C++とGoは通常AOTで事前コンパイルされるため、両者を同じ表に混ぜないことを求める。C++ではコンパイラが未使用の計算を消してしまうデッドコード除去への対策としてGoogle Benchmarkやchecksumによる正しさ確認が必要になり、言語内の測定にはBenchmarkDotNet・JMH・go test -bench・Google Benchmarkといった専用ハーネスを使い、言語をまたぐ比較は外側の共通ランナーで実行順序のランダム化とchecksum検証を行う2層構成が推奨される。さらに平均だけでなく中央値やp95/p99のようなテールレイテンシの分布まで見ることが、結果を正しく解釈する条件として整理されている。
flowchart LR
accTitle: C#/C++/Java/Go言語横断ベンチマークの知識マップ
accDescr: C#とJavaのJITコンパイルとTiered Compilation、C++とGoの事前コンパイルという違いがcoldとwarmの区別を要求すること、C++のデッドコード除去対策としてのGoogle Benchmarkとchecksum検証、言語別ハーネスと外側の共通ランナーによる2層構成、テールレイテンシの分布評価までの関係を示す図。
cross_language_benchmarking["言語横断ベンチマーク設計"]
csharp["C#"]
jit_compilation["JIT(Just-In-Time)コンパイル"]
java_lang["Java"]
cpp["C++"]
ahead_of_time_compilation["AOT(事前)コンパイル"]
golang["Go"]
tiered_compilation["Tiered Compilation"]
gomaxprocs["GOMAXPROCS"]
cgo["cgo"]
benchmarkdotnet["BenchmarkDotNet"]
jmh["JMH(Java Microbenchmark Harness)"]
go_benchmark_testing["go test -bench と benchstat"]
google_benchmark["Google Benchmark"]
warm_up_measurement["ウォームアップ後の安定状態計測(warm)"]
dead_code_elimination["デッドコード除去(最適化による処理の消滅)"]
checksum_verification["checksumによる正しさ確認"]
tail_latency_percentile["テールレイテンシのパーセンタイル(p95/p99)"]
garbage_collection["ガベージコレクション(GC)"]
csharp -.->|"利用する"| jit_compilation
java_lang -.->|"利用する"| jit_compilation
cpp -->|"利用する"| ahead_of_time_compilation
golang -->|"利用する"| ahead_of_time_compilation
csharp -->|"利用する"| tiered_compilation
golang -->|"利用する"| gomaxprocs
golang -.->|"利用する"| cgo
csharp -.->|"利用する"| benchmarkdotnet
java_lang -.->|"利用する"| jmh
golang -.->|"利用する"| go_benchmark_testing
cpp -.->|"利用する"| google_benchmark
benchmarkdotnet -->|"利用する"| warm_up_measurement
jmh -->|"利用する"| warm_up_measurement
google_benchmark -.->|"防止する"| dead_code_elimination
cpp -.->|"原因になり得る"| dead_code_elimination
checksum_verification -->|"防止する"| dead_code_elimination
cross_language_benchmarking -->|"利用する"| tail_latency_percentile
csharp -->|"利用する"| garbage_collection
java_lang -->|"利用する"| garbage_collection
golang -->|"利用する"| garbage_collection
cross_language_benchmarking -.->|"利用する"| warm_up_measurement
cross_language_benchmarking -->|"利用する"| checksum_verification
cross_language_benchmarking -->|"利用する"| jit_compilation
cross_language_benchmarking -->|"利用する"| ahead_of_time_compilation
図の実線は常に成り立つ関係、破線は条件付きの関係です(成立条件は詳細ページの各関係の説明に記載)。関係すべての一覧(全24件、根拠・確度つき)と主要概念の定義は知識マップ詳細ページにまとめています。データ: JSON-LD / Turtle
最初に決めるべきこと
「速い」を 1 個の言葉で済ませると、だいたい事故ります。 まずは 何を速いと呼ぶのか を決めます。
たとえば、同じプログラムでも見たいものはかなり違います。
1. 起動時間を見たいのか
CLI ツール、短命なバッチ、1 回起動してすぐ終わる補助ツールなら、cold start や process startup が効きます。 この軸では、JIT やクラスロードの初期化コストを含むかどうかで結果が大きく変わります。
2. 長時間運転の throughput を見たいのか
サーバー、常駐プロセス、ワーカー、長く動く変換処理なら、steady-state の throughput が重要です。 この場合、初回だけ遅いこと自体は本質ではなく、warm-up 後にどこまで安定して伸びるかが主題になります。
3. tail latency を見たいのか
API、UI、リアルタイム寄りの処理では、平均より p95 / p99 のほうが大事なことがあります。 平均が速くても、たまに大きく止まるなら、ユーザー体験や SLA 的にはつらいです。
4. メモリ効率も含めて見たいのか
CPU 時間だけでなく、最大 RSS、割り当て量、GC 回数、GC pause も見ないと、実運用の重さは読み違えます。 「速いけどメモリをかなり食う」と「少し遅いけど安定して軽い」は、用途によって評価が逆転します。
要するに、最初に決めるべき問いは
この比較で知りたいのは、どの言語が速いか ではなく、 どの workload を、どの条件で、どの指標において速く処理できるか
です。
ここを曖昧にしたまま数字を集めると、最後にまとまりません。
なぜ言語比較は難しいのか
JIT と AOT を混ぜると別の実験になる
C# と Java は通常 JIT の影響を受けます。 一方で、C++ と Go は通常は事前コンパイル済みです。
つまり、初回実行を測れば、プログラム本体の速さ だけでなく、ランタイムの起動・クラスロード・JIT 準備 も一緒に測ることになります。 逆に、十分に warm-up した後だけを見るなら、今度は 定常状態の最適化がどこまで効くか の比較になります。
どちらも意味はあります。 ただし、同じ意味ではありません。
言語差より実装差のほうが大きいことが普通にある
同じ「ソート」でも、
- 片方は標準ライブラリを使っている
- 片方は自前実装
- 片方は余分なコピーをしている
- 片方は入力を毎回再生成している
これだけで結果はかなり変わります。
さらに、JSON、圧縮、暗号、正規表現のような処理になると、言語そのもの より ライブラリ実装 の差がかなり効きます。 そのため、何を測っているのかを明示しないと、「言語比較」のつもりが「ライブラリ比較」になります。
C++ は最適化で処理が消える罠がある
特に microbenchmark では、コンパイラが「この計算結果、誰も使っていないな」と判断すると、処理を消してしまうことがあります。 すると、速いのではなく、そもそも何もしていない という結果を測ることになります。ループ全体が消えて、実行時間がほぼゼロになるのが典型的な見え方です。
C++ ではこの問題が特に露骨に出やすいので、結果の使用や checksum の出力、あるいは benchmark フレームワークの最適化抑制機能がかなり重要です。
GC の存在は「不利」でも「有利」でもなく、特性
C#、Java、Go には GC があります。 これを単純に「GC があるから遅い」とすると雑すぎます。
実際には、
- 大量短命オブジェクトをどうさばくか
- ヒープサイズ設定
- GC の頻度と pause
- オブジェクトレイアウト
- ライブラリの割り当て癖
のほうが効きます。
逆に C++ は手動管理や RAII で細かく制御できますが、そのぶん設計や実装の差が出やすいです。 つまり、管理方式の違いが、そのまま善悪や優劣ではない です。
比較でやってはいけないこと
1. Debug と Release を混ぜる
これは論外です。 比較対象は必ず 本番相当の最適化ビルド に揃えます。
2. 同じ問題を解いていない
入力形式が違う、出力が違う、エラー処理が片方だけない、メモリ再利用の方針が違う。 このへんを放置すると、速さではなく要件差 を測ってしまいます。
3. 1 回だけ実行して結論を出す
1 回だけの実行は、だいたいノイズです。
- JIT
- ページキャッシュ
- CPU のブースト
- 熱
- バックグラウンドタスク
- GC
- 初回のファイル読み込み
このへんが 1 回で全部混ざります。
4. warm-up を混ぜる
C# と Java を測るとき、初回を含めるのか、warm-up 後だけを見るのかを曖昧にすると、議論が崩れます。 cold と warm は別物 として扱います。
5. 正しさ確認をしない
ベンチは「速い」より先に「同じ結果を返す」が必要です。 比較対象の全実装で、同じ入力から同じ checksum や同じ出力 が得られることを必ず確認します。
6. 1 本の microbenchmark だけで世界観を決める
tight loop だけで勝っても、実サービス全体で勝つとは限りません。 逆に、起動時間で負けても、長時間運転では十分強いこともあります。
C# / C++ / Java / Go を比べるときの基本方針
ここはかなり重要です。 おすすめは 2 層構成 です。
flowchart TB
subgraph outer["外側の層: 言語横断の共通ランナー"]
direction TB
R["共通ランナー<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
外側の層で出る数字が 言語をまたいで比べてよい数字、内側の層で出る数字が その言語の中で改善を追うための数字 です。この 2 つを 1 つの表に混ぜないのが要点になります。
1. 言語内の測定は、その言語に合ったハーネスを使う
各言語には、その言語の事情を吸収してくれる benchmark ツールがあります。
- C#: BenchmarkDotNet
- Java: JMH
- Go:
go test -benchとbenchstat - C++: Google Benchmark
これらは、それぞれのランタイム事情や統計処理、測定の罠をある程度面倒見てくれます。 言語内での比較 や 実装の掘り下げ にはかなり有効です。
たとえば「1,000 万件の int32 をソートする」を BenchmarkDotNet で測ると、最小構成はこうなります。
// 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 は、このレベルは 1 回の @Benchmark メソッド呼び出しが 1 ミリ秒を超えるベンチでしか使えない と明記しています。呼び出しごとにタイムスタンプを取るため、短い処理では計測そのものがボトルネックになるからです。
BenchmarkDotNet と JMH の @Warmup / @Measurement / @Fork の値は、そのまま 実験条件 です。結果と一緒に必ず残してください。
2. 言語横断の比較は、外側に共通ランナーを置く
一方で、C# の BenchmarkDotNet の結果 と Java の JMH の結果 を、そのまま横に並べるのは少し危ないです。 ハーネス自体の作法が違うからです。
そのため、言語横断では、各実装を 同じ 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 行だけを出す約束にしておくと、ランナー側の解析が 1 行の正規表現で済みます。
checksum=[16進の文字列]
inner_ms=[小数のミリ秒]
checksum は正しさ確認用、inner_ms は実装が自分で測った本体処理の時間です。ランナー側はプロセス全体の wall-clock を別に測るので、起動込みの時間 と 本体だけの時間 の両方が残ります。
そして共通ランナー側で、
- 実行順序をランダム化する
- cold / warm を分ける
- 同じデータセットを渡す
- checksum を検証する
- wall-clock とメモリを採る
- CSV / JSON に raw data を残す
という流れにします。骨格だけ書くと、こうなります。
# run-bench.ps1 : 言語横断の共通ランナー(骨格)
# 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
# 実装との出力契約を、ここで 1 か所だけ解析します
$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 も 1 本ずつ
# 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 が食い違ったまま速度だけ比べても意味がありません
- 1 run ごとにシャッフルする。A を全部回してから B、という順番にしないのが目的です
- 集計せずに raw data だけ書き出す。平均や中央値は、あとから CSV に対して出します
これをやると、各言語の中のベストプラクティス と 言語横断の公平さ を分けて扱いやすくなります。
具体例: どんなベンチ項目を用意するべきか
「C# / C++ / Java / Go を比べたい」と言われたとき、1 本だけなら 誤解しにくい単純な 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
目的: 並列処理、スケジューラ、ワーカープール、同期の癖を見る
- 入力: 固定サイズのバイナリチャンク列
- 処理: N スレッドで順にハッシュ化し、最終 checksum を返す
- 条件: スレッド数を 1 / 2 / 4 / 8 のように段階化
単純な tight loop よりも、並列実行時の伸び方 が見えやすいです。
4. startup_noop または startup_parse_small
目的: 起動時間を見る
noop: 起動してすぐ終了parse_small: 小さな入力を 1 回だけ処理して終了
ここでは C# / Java の JIT や初期化コストが見えやすく、C++ / Go の見え方とだいぶ変わります。 逆に言うと、ここで差が出ても、長時間処理の勝敗とは別です。
JSON や HTTP ベンチはどうするか
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 は比較的扱いやすいですが、並列ベンチでは GOMAXPROCS の影響が大きいです。
また、cgo を使うかどうかで世界が変わるので、そこは必ず条件に残します。
実行環境の揃え方
どの言語でも、環境を揃えない比較は、だいたい環境を比べています。
揃えるべきもの
- 同じ CPU / メモリ / ストレージ
- 同じ OS バージョン
- 同じ電源条件
- 同じ室温に近い条件
- 同じ入力データ
- 同じプロセス優先度
- 同じコア数条件
- 同じコンテナ or ベアメタル条件
特に効くもの
電源設定と CPU 周波数
ノート PC だと、AC 接続かバッテリーかだけでも別世界になります。 CPU governor や power mode が揃っていないと、比較結果がかなりぶれます。
Windows での電源条件、通知、バックグラウンドノイズ、熱、実行順序の揃え方については、別記事の Windowsで異なるバージョンのプログラムの実行速度をいかに比較するか で詳しく整理しています。Windows で測るなら、ここはかなり効きます。
熱
最初の数回だけ速く、後半で落ちるなら、熱やスロットリングを疑います。 A を全部回してから B を全部回すより、A / B / A / B のように交互に回したほうが偏りを減らせます。
バックグラウンド処理
更新、インデックス、同期、ウイルススキャン、ブラウザ、チャットツール。 このへんは地味ですが、普通に刺さります。
何を測るべきか
言語比較では、最低でもこの 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
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 を取って原因を掘る"]
1. workload を決める
まず、何を比較したいのかを明確にします。
- 起動時間
- 定常 throughput
- tail latency
- メモリ効率
- 並列スケール
2. 共通データセットを固定する
入力データは固定 seed か固定ファイルで揃えます。 データ生成まで含めてしまうなら、それも各言語で同じ条件にする必要があります。
3. 正しさ確認を先に通す
小さいデータと大きいデータで、全実装が同じ結果を返すことを確認します。 checksum やハッシュを出させると扱いやすいです。
4. build 条件を固定する
各言語で Release / 最適化済みの実行形式を作り、バージョンとフラグを記録します。
5. cold と warm を分ける
特に C# と Java はここが重要です。
- cold: プロセス起動直後を含む
- warm: 数回実行後の安定状態
どこまでを測っているかを図にすると、この 2 つが別物であることがはっきりします。
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
C++ と Go には、事前コンパイル済みである以上「JIT の初回コンパイル」にあたる工程がなく、ランタイム初期化も比較的軽く済みます。cold の差の多くはここで生まれます。だからこそ、この 2 つは同じ表に混ぜないほうがきれいです。
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 の挙動
- 余分なコピー
のほうが効いていることが多いです。
記録テンプレート
ベンチ結果には、最低でもこのくらいの項目を残しておくと後で助かります。
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 |
ベンチ項目名 | 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 |
処理系のバージョン。マイナーまで書きます | 処理系が出すバージョン文字列 |
flags |
最適化条件。/O2、-O3 -flto、Server GC、GOMAXPROCS=8 など |
空白区切りの文字列 |
os / cpu / threads |
実行環境 | OS 名とビルド番号、CPU 型番、使用スレッド数 |
input_id |
データセットの識別子。ファイルのハッシュにしておくと確実です | ファイル名とハッシュ |
notes |
異常があった run のメモ | 自由記述 |
測定値の列は空欄を許し、空欄であること自体を残す のが要点です。「C++ だから gc_count は無い」は情報ですが、列ごと消してしまうと後から分かりません。
平均だけを見ると何を見落とすか
集計のとき、平均 1 本にまとめると情報が飛びます。次は 算数を説明するための架空の数字 で、どの言語の実測値でもありません。ある 1 実装の 10 run を速い順に並べた elapsed_ms だとします。
98, 99, 100, 101, 101, 102, 103, 104, 106, 720
この 10 個から出る代表値はこうなります。
| 指標 | 値 | 読み方 |
|---|---|---|
| 平均 | 163.4 | 最後の 1 回に引きずられて、実際の常用域から 6 割ほど上へずれています |
| 中央値 | 101.5 | 10 回中 9 回の実感に近い値です |
| min / max | 98 / 720 | 差が 7 倍以上あるので、まず外れ値の原因を調べる合図になります |
| p95 / p99 | 出せない | 10 サンプルでは 95 パーセンタイルも 99 パーセンタイルも意味を持ちません |
つまり、平均だけを載せた表は「たまに大きく止まる実装」と「安定して少し遅い実装」を同じ顔にしてしまいます。表の作りとしては、次の差が効きます。
| 観点 | 弱い結果表 | 使える結果表 |
|---|---|---|
| 代表値 | 平均だけ | 中央値を主役にし、min / max とばらつきを併記 |
| 試行回数 | 書いていない | run 数と、外れ値を除いたかどうかの方針を明記 |
| cold / warm | 混ざっている、または区別なし | 別の表、または別の行として分離 |
| 正しさ | 触れていない | 全実装で checksum が一致したことを明記 |
| 条件 | 「同じ PC で測りました」 | OS、CPU、処理系のバージョン、最適化フラグ、スレッド数まで記載 |
| 生データ | 集計値だけ | raw CSV の保存場所を併記 |
p95 や p99 を載せたいなら、そもそも run 数を増やす必要があります。分布を語るには、分布が見える数のサンプルが要る というだけの話です。
ベンチは、測ること より 後から解釈できること のほうが大事だったりします。
まとめ
C# / C++ / Java / Go の速度比較で本当に大事なのは、 どの言語が最速か という雑な問いを、 どの workload を、どの条件で、どの指標で比較するのか という実験の形に落とすことです。
特に外しにくいのは、このあたりです。
- 起動時間と定常状態を分ける
- 同じアルゴリズム、同じ入力、同じ正しさ確認で測る
- 1 本のベンチだけで結論を出さない
- 言語内の benchmark と言語横断の benchmark を分ける
- 平均より中央値と分布を見る
- 条件と raw data を残す
そして最後にいちばん大事なのは、言語名で勝敗を決めようとしすぎないこと です。 現実の性能は、言語、ランタイム、ライブラリ、ビルド条件、データ、OS、ハードウェアの合わせ技で決まります。
「C++ が速い」「Java が強い」「Go が軽い」「C# でも十分速い」という話は、全部ある意味では正しいです。 ただし、どの条件でそう言っているのか が抜けると、議論が噛み合わないまま終わります。
条件を揃えて、複数の workload で、cold / warm を分けて、分布まで見る。 地味ですが、結局これがいちばん強いです。
参考資料
-
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/blog/2026/03/16/002-windows-benchmark-comparing-program-versions/
関連トピック
この記事とあわせて見ると理解しやすいページです。
このテーマの相談先
性能比較の設計、計測条件の揃え方、結果の解釈、原因の掘り下げは、次のサービスと相性がよいテーマです。
関連する記事
同じタグを共有する最新の記事です。さらに近い話題で知識を深められます。
DLL・COMインターフェースの後方互換性 ── どの変更が呼び出し側を壊すのかの判断表
DLLやCOMコンポーネントのどの変更が呼び出し側を壊すのか。バイナリ互換・ソース互換・動作互換の3層を整理し、変更内容別の判断表、COMインターフェース不変の鉄則、semver運用までを実務ガイドとしてまとめます。
Windowsアプリで子プロセスを安全に扱うチェックリスト
Windows アプリで子プロセスを安全に扱うには、起動 API よりもプロセス木の所有権と終了手順の設計が重要です。Job Object、終了伝播、標準入出力、watchdog を整理します。
共有メモリの落とし穴と実務ベストプラクティス
共有メモリを実務で使うときの落とし穴と、同期、可視性、寿命、ABI、権限まで含めた事故率を下げる設計を整理します。
Windowsでプログラムのバージョン別速度を正しく比較する方法
Windows でプログラムのバージョン差を比較するときに、power mode、power plan、熱、バックグラウンドノイズ、測定指標、ETW / WPR まで含めて再現性の高い手順を整理します。
C# Native AOT DLLをC/C++から呼び出す方法
C# のクラスライブラリを Native AOT でネイティブ DLL として発行し、UnmanagedCallersOnly のエントリポイントを C/C++ から呼び出す構成を、使いどころと実装パターンと注意点から整理します。
関連トピック
このテーマと近いトピックページです。記事を起点に、関連するサービスや他の記事へ進めます。
Windows技術トピック
Windows 開発、不具合調査、既存資産活用の技術トピックをまとめた入口です。
このテーマがつながるサービス
この記事は次のサービスページにつながります。近い入口からご覧ください。
技術相談・設計レビュー
性能比較の設計、計測条件の揃え方、warm-up や統計の読み方まで含めて、技術相談・設計レビューと相性がよいテーマです。
不具合調査・原因解析
言語やバージョンをまたいだ性能差の原因切り分け、ボトルネックの特定、測定手順の妥当性確認は、不具合調査・原因解析として進めやすいです。
よくある質問
この記事のテーマについて、相談時によくある質問をまとめています。
- C#・C++・Java・Goのどれが一番速いのですか?
- 1本の数字では決められません。現実の性能は言語、ランタイム、ライブラリ、ビルド条件、データ、OS、ハードウェアの合わせ技で決まるからです。大事なのは「どの言語が最速か」という問いを「どのworkloadを、どの条件で、どの指標で比較するのか」という実験の形に落とすことです。起動時間ではC#/JavaのJITや初期化コストが見えやすい一方、steady-stateではJITの最適化が効いてかなり追いつく、あるいは逆転することも珍しくありません。
- C#やJavaのベンチマークでwarm-upを分けるのはなぜですか?
- C#とJavaは通常JITの影響を受けるため、初回実行を測るとプログラム本体の速さだけでなく、ランタイムの起動・クラスロード・JIT準備も一緒に測ることになるからです。一方C++とGoは通常事前コンパイル済みです。coldとwarmはどちらも意味がありますが同じ意味ではないので、プロセス起動直後を含むcoldと数回実行後の安定状態であるwarmは別物として扱い、同じ表に混ぜないほうがきれいです。
- 言語をまたいだベンチマークはどう設計すべきですか?
- 2層構成がおすすめです。言語内の測定はBenchmarkDotNet(C#)、JMH(Java)、go test -benchとbenchstat(Go)、Google Benchmark(C++)のようにその言語に合ったハーネスを使います。言語横断の比較では、各ハーネスの結果をそのまま並べるのは危ないため、各実装を同じCLI契約で呼べる実行ファイルにして、外側の共通ランナーで実行順序のランダム化、cold/warmの分離、同一データセットの使用、checksum検証、raw dataの保存を行う形が筋がよいです。
- C++のマイクロベンチマークで気をつけることは何ですか?
- 最適化で処理が消える罠に注意が必要です。コンパイラが「この計算結果は誰も使っていない」と判断すると処理自体を消してしまい、速いのではなくそもそも何もしていない結果になることがあります。そのため結果の使用やchecksumの出力、benchmarkフレームワークの最適化抑制機能が重要です。またC++は条件差がそのまま大きく出るため、どのコンパイラで、どのフラグ(-O3/O2、LTO、PGOなど)で、どのSTLで測ったかを明記することがかなり重要です。