引用本文(DOI: 10.5281/zenodo.21615496)
本文保存于 Zenodo。以下同时提供始终指向最新版本的 DOI,以及固定于您正在阅读版本的 DOI。
小村 豪(2026)。《如何公平比较 C#/C++/Java/Go 的运行速度》。小村软件有限公司。https://doi.org/10.5281/zenodo.21615496 https://comcomponent.com/zh-CN/blog/2026/03/17/000-language-benchmark-csharp-cpp-java-go/
- DOI(最新版本)
- 10.5281/zenodo.21615496
- DOI(此版本)
- 10.5281/zenodo.22282016
“听说 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,语言内部的工具用 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 代码的操作系统线程数上限。并行基准测试中不固定这个值就无法比较结果 |
| cgo | 从 Go 调用 C 代码的机制。启用后调用开销、构建条件、能否静态链接都会改变 |
先说结论
在 C# / C++ / Java / Go 的速度比较中,真正起作用的是这 7 点。
-
先确定想比较的是什么速度 是启动时间,是稳定状态下的 throughput,是 p95 延迟,还是内存效率,测量方式会随之改变。
-
不要只用一个基准测试就下结论 在 CPU 计算、内存分配、并行处理、启动时间上,哪种语言和运行时看起来更强会发生变化。
-
C# 和 Java 要区分 cold 与 warm 把包含首次执行的比较,和 warm-up 之后的稳定状态比较混在一起,讨论就会拧巴。
-
用相同的算法、相同的输入、相同的正确性校验来测量 测到的不是更快的实现,而只是在解另一道题,这是基准测试里的常见问题。
-
把语言内部的 microbenchmark 与跨语言的 end-to-end 基准测试分开 各语言的专用工具很方便,但跨语言的比较更适合放到外层的统一 runner 里跑。
-
不只看平均值,还要看中位数与分布 哪怕只有一次撞上 GC 或后台处理,平均值就会被毁掉。
-
不只留下数字,还要留下条件 基准测试结果既是速度的记录,也是实验条件的记录。没有写下条件的结果,事后会相当难受。
图中实线表示始终成立的关系,虚线表示带条件的关系(成立条件写在详情页各关系的说明中)。关系的完整列表(共 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,或者使用基准测试框架抑制优化的功能,都相当重要。
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 的 boost
- 发热
- 后台任务
- GC
- 首次的文件读取
这些会在一次执行里全部混进来。
flowchart TB
accTitle: 只执行一次时混进来的东西
accDescr: 表示只执行一次时 JIT 与首次文件读取、页缓存与 CPU boost、发热与后台任务、GC 会全部混在一起,因此一次的结果基本上就是噪声的图。
x1["JIT 与首次读取"] --> x4["一次执行把所有东西混在一起"]
x2["缓存与 CPU boost"] --> x4
x3["发热与后台处理"] --> x4
x4 --> x5["只跑一次的结果基本上是噪声"]
图7:只有一次的数字里,混入的最多的恰恰是不想测的东西。
4. 混用 warm-up
测量 C# 和 Java 时,如果对包含首次执行还是只看 warm-up 之后含糊其辞,讨论就会崩掉。 要把 cold 与 warm 当作两回事来处理。
5. 不做正确性校验
基准测试在“快”之前,先要保证“返回相同的结果”。 必须确认所有被比较的实现,从相同的输入得到相同的 checksum 或相同的输出。
6. 只凭一个 microbenchmark 就决定世界观
在 tight loop 里赢了,不代表在整个实际服务中也能赢。 反过来,在启动时间上输了,长时间运行下也可能足够强。
比较 C# / C++ / Java / Go 时的基本方针
这里相当重要。 推荐的是两层结构。
flowchart TB
subgraph outer["外层:跨语言的统一 runner"]
direction TB
R["统一 runner<br/>随机化执行顺序 / 分离 cold 与 warm<br/>校验 checksum / 保存原始数据"]
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,语言内部的深入挖掘交给专用工具。
外层跑出来的数字是可以跨语言比较的数字,内层跑出来的数字是在该语言内部追踪改进用的数字。要点在于不要把这两者混进同一张表。
1. 语言内部的测量,使用适合该语言的工具
每种语言都有能吸收该语言自身情况的 benchmark 工具。
- C#:BenchmarkDotNet
- Java:JMH
- Go:
go test -bench与benchstat - C++:Google Benchmark
它们会在一定程度上帮你照顾各自的运行时情况、统计处理,以及测量中的陷阱。 对语言内部的比较和实现层面的深入挖掘相当有效。
例如,用 BenchmarkDotNet 测量“对 1000 万个 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 则是有用的。1000 万个元素的排序满足这个条件,但测量耗时较短的处理时,请改成在 [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 毫秒的基准测试。因为它会在每次调用时取时间戳,对耗时较短的处理来说,测量本身就成了瓶颈。
BenchmarkDotNet 与 JMH 的 @Warmup / @Measurement / @Fork 的取值,本身就是实验条件。请务必和结果一起留下来。
2. 跨语言的比较,把统一 runner 放在外层
另一方面,把 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 行,runner 一侧用一行正则表达式就能解析完。
checksum=[十六进制字符串]
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 与内存
- 把原始数据留在 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= 两行"
}
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 这样的顺序
- 不做汇总,只写出原始数据。平均值和中位数之后对着 CSV 来算
这样做之后,就更容易把各语言内部的最佳实践与跨语言的公平性分开处理。
flowchart TB
accTitle: 统一 runner 的三个意图
accDescr: 表示统一 runner 中刻意做了三件事,即把基于 checksum 的正确性校验放在速度测量之前、每个 run 都打乱执行顺序、不做汇总只写出原始数据的图。
rn1["正确性校验先于速度"] --> rn4["能够公平比较的测量"]
rn2["每个 run 都打乱顺序"] --> rn4
rn3["不汇总,写出原始数据"] --> rn4
rn2 -.-> rn5["抹平发热与时间段的偏差"]
图10:runner 的工作不是跑得快,而是消除疑虑。
具体示例:应该准备哪些基准测试项目
被要求“想比较 C# / C++ / Java / Go”时,只做一个的话推荐不容易被误解的单纯 CPU 类项目,要做多个的话推荐准备性质不同的 3~4 种 workload。
推荐的组合
1. sort_int32_10m
目的: 观察 CPU + 内存带宽 + 临时空间的使用方式
- 输入:用固定 seed 生成的 1000 万个
int32 - 处理:对数组排序并返回 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:只处理一次小规模输入后结束
在这里,C# / Java 的 JIT 与初始化成本比较容易显现,与 C++ / Go 的表现相差很多。 反过来说,即使这里出现差距,也和长时间处理的胜负是两回事。
flowchart TB
accTitle: 性质不同的四个基准测试项目
accDescr: 表示排序看 CPU 与内存带宽、单词计数看哈希与字符串以及内存分配、并行哈希看并行执行时的提升幅度、启动基准测试看启动时间,各自能看到的东西都不同的四项构成的图。
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 类基准测试怎么办
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 会让世界完全不同,这一点一定要写进条件里。
执行环境的统一方式
无论哪种语言,不统一环境的比较,基本上比的都是环境。
flowchart TB
accTitle: 不统一环境的比较的真面目
accDescr: 表示在 CPU、操作系统、电源条件、输入数据、优先级与核心数等环境没有统一的比较中,结果的差异不是语言的差异而是环境的差异的图。
en1["环境没有统一的两次测量"] --> en2["结果出现差异"]
en2 --> en3{"这个差异是什么的差异"}
en3 -->|"如果环境统一了"| en4["可以读作实现或语言一侧的差异"]
en3 -->|"如果没有统一"| en5["只是在比较环境而已"]
图12:只有在消除了环境差异之后,才能把差异归到语言头上。
应该统一的项目
- 相同的 CPU / 内存 / 存储
- 相同的操作系统版本
- 相同的电源条件
- 接近相同室温的条件
- 相同的输入数据
- 相同的进程优先级
- 相同的核心数条件
- 相同的容器或裸金属条件
特别起作用的因素
电源设置与 CPU 频率
在笔记本电脑上,仅仅是接交流电还是用电池,就已经是两个世界。 如果 CPU governor 或 power mode 没有统一,比较结果会波动很大。
关于 Windows 下的电源条件、通知、后台噪声、发热、执行顺序的统一方式,在另一篇文章 如何比较 Windows 上不同版本程序的执行速度 中有详细整理。如果在 Windows 上测量,这部分影响相当大。
发热
如果只有最初几次快,后半段掉下去,就要怀疑发热或降频。 比起把 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: 分开来看的四个指标
accDescr: 表示语言比较中要分开来看用户等待的实际时间 wall-clock、实际使用的 CPU 时间、最大 RSS 与分配量等内存、中位数与百分位数等分布这四项的图。
ms1["wall-clock time"] --> ms5["四项分开来看"]
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. 保存原始数据"]
a9 --> a10{"是否出现了<br/>有意义的差异"}
a10 -- 否 --> a11["记录条件与次数后结束"]
a10 -- 是 --> a12["9. 采集 profile 深挖原因"]
图15:从确定 workload 到正确性校验、随机化的正式测量、保存原始数据的执行步骤。
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. 保存原始数据
不只保存汇总结果,还要留下每次 run 的原始数据。 事后回看时,能读出离群值和 warm-up 的习性。
9. 出现差异后再采集 profile
出现差异时,才开始深挖原因。
- CPU profile
- allocation profile
- GC 日志
- flame graph
- 操作系统一侧的 trace
做到这一步,讨论的就不再是“快 / 慢”,而是为什么会这样了。
结果的解读方式
数字出来之后,解读方式错了同样很危险。
只有首次执行时 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:比起数字的大小,先确认这个差异属于哪个擂台。
记录模板
基准测试结果里,至少留下这些字段,事后会省心。
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 |
用于正确性校验。全部实现是否一致要另行检查 | 十六进制字符串 |
compiler_or_runtime |
处理系统的种类 | msvc / dotnet / temurin / go |
compiler_version |
处理系统的版本。写到次版本号 | 处理系统输出的版本字符串 |
flags |
优化条件。/O2、-O3 -flto、Server GC、GOMAXPROCS=8 等 |
用空格分隔的字符串 |
os / cpu / threads |
执行环境 | 操作系统名与构建号、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 | 被最后那一次拖着,比实际的常用区间高出了六成左右 |
| 中位数 | 101.5 | 接近 10 次里 9 次的实际感受 |
| min / max | 98 / 720 | 相差 7 倍以上,是先去查离群值原因的信号 |
| p95 / p99 | 算不出来 | 只有 10 个样本时,95 百分位和 99 百分位都没有意义 |
也就是说,只登载平均值的表,会把“偶尔大幅停顿的实现”和“稳定地稍慢一点的实现”变成同一张脸。就表的做法而言,下面这些差别很起作用。
| 观点 | 弱的结果表 | 能用的结果表 |
|---|---|---|
| 代表值 | 只有平均值 | 以中位数为主,并列出 min / max 与离散程度 |
| 试验次数 | 没有写 | 写明 run 数,以及是否剔除离群值的方针 |
| cold / warm | 混在一起,或者不加区分 | 分成不同的表,或者分成不同的行 |
| 正确性 | 没有提及 | 写明全部实现的 checksum 一致 |
| 条件 | “在同一台电脑上测的” | 写到操作系统、CPU、处理系统的版本、优化选项、线程数 |
| 原始数据 | 只有汇总值 | 同时写明原始 CSV 的保存位置 |
想登载 p95 或 p99,本来就需要增加 run 数。说白了就是:要谈分布,就得有能看出分布的样本数。
基准测试有时候比起测量本身,事后能否解读更重要。
flowchart TB
accTitle: 一个平均值掩盖了什么
accDescr: 表示只要有一次较大的离群值平均值就会大幅偏离常用区间,中位数更接近实际感受,min 与 max 相差很大时是去查离群值原因的信号,因此只有平均值的表很危险的图。
av1["只有一次出现较大离群值"] --> av2["平均值向上偏离常用区间"]
av2 --> av3["只有平均值的表掩盖了实态"]
av3 --> av4["同时列出中位数与 min 和 max"]
av4 -.-> av5["离群值是去查原因的信号"]
图18:平均值不说谎,但它对离群值的存在闭口不谈。
总结
C# / C++ / Java / Go 速度比较中真正重要的, 是把哪个语言最快这个粗糙的问题, 落实为针对哪种 workload,在什么条件下,用什么指标来比较 这样的实验形式。
尤其不容易出错的是这几点。
- 把启动时间与稳定状态分开
- 用相同的算法、相同的输入、相同的正确性校验来测量
- 不要只用一个基准测试就下结论
- 把语言内部的 benchmark 与跨语言的 benchmark 分开
- 比起平均值,更看中位数与分布
- 留下条件与原始数据
而最后最重要的是,不要过于想用语言名称来决定胜负。 现实中的性能,是由语言、运行时、库、构建条件、数据、操作系统、硬件综合作用决定的。
“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 上不同版本程序的执行速度 /zh-CN/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 到底哪个最快?
- 没办法用一个数字来决定。因为现实中的性能是由语言、运行时、库、构建条件、数据、操作系统、硬件综合作用决定的。重要的是把“哪个语言最快”这个问题,落实为“针对哪种 workload,在什么条件下,用什么指标来比较”这样的实验形式。在启动时间上,C# 与 Java 的 JIT 和初始化成本比较容易显现;而在 steady-state 下,JIT 优化生效后往往会明显追上,甚至反超,这种情况并不少见。
- 为什么在 C# 或 Java 的基准测试中要区分 warm-up?
- 因为 C# 和 Java 通常会受到 JIT 的影响,只测量首次执行时,测到的不仅是程序本身的速度,还会连带测到运行时启动、类加载、JIT 准备。而 C++ 和 Go 通常是预先编译好的。cold 和 warm 都有意义,但意义并不相同,因此应该把包含进程启动瞬间的 cold,与经过若干次执行后进入稳定状态的 warm 当作两回事来处理,不要混进同一张表里。
- 跨语言的基准测试应该如何设计?
- 推荐两层结构。语言内部的测量使用适合该语言的工具,例如 BenchmarkDotNet(C#)、JMH(Java)、go test -bench 与 benchstat(Go)、Google Benchmark(C++)。跨语言比较时,直接把各个工具的结果并排放在一起是危险的,比较稳妥的做法是把各个实现做成能用同一套 CLI 约定调用的可执行文件,再由外层的统一 runner 负责随机化执行顺序、区分 cold 与 warm、使用同一数据集、校验 checksum、保存原始数据。
- 在 C++ 的微基准测试中需要注意什么?
- 需要注意优化会让处理消失的陷阱。如果编译器判断“这个计算结果没有任何地方用到”,就可能把处理本身消除掉,结果不是变快了,而是根本什么都没有做。因此,让结果被实际使用、输出 checksum,以及基准测试框架抑制优化的功能都很重要。此外,C++ 的条件差异会直接放大为结果差异,所以写清楚用哪个编译器、哪些编译选项(-O3/O2、LTO、PGO 等)、哪个 STL 实现来测量,是相当重要的。