1 运行 Benchmark
1.1 创建项目
使用 Rider(2024.1.4 版本)创建新项目。注意是否勾选“将解决方案和项目放在同一目录中”,这会导致后续 Benchmark 运行目录不同。此处没有勾选。
1.2 安装 Benchmark
- 点击左侧目录,打开 NuGet 包管理器。
- 搜索 benchmark,找到 “BenchmarkDotNet” 并安装。
1.3 编写测试脚本
这里编写了两种判断字符串子串的方法:一种使用 Contains 方法判断,另一种使用 IndexOf 判断。
通过为方法添加 [Benchmark]
特性,来标识这是我们想要测试的方法。
// See https://aka.ms/new-console-template for more information
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
[MemoryDiagnoser(false)]
public class BenchmarkClass
{
public string message = "Hello, World!";
[Benchmark]
public bool Contains() { // 测试字符串 Contains 方法
return message.Contains("World");
}
[Benchmark]
public bool IndexOf() { // 测试字符串 IndexOf 方法
return message.IndexOf("World", StringComparison.Ordinal) != -1;
}
public static void Main(string[] args) {
BenchmarkRunner.Run<BenchmarkClass>();
}
}
1.4 运行 Benchmark
打开终端。
-
创建项目时,如果**未勾选**“将解决方案和项目放在同一目录中”,则需要先进入项目所在目录。
以本教程为例,此处需要先输入命令:cd .\BenchmarkSample\。
如果**勾选**“将解决方案和项目放在同一目录中”,则已经在项目路径下,直接到第 2 步。
-
输入命令:dotnet run -c Release,即可运行 Benchmark 测试(Benchmark 需要在 Release 模式下才能运行)。
此处的结果比较如下:
可以看出,Contains 方法运行时间更短,效率更高。
1.5 查看运行日志
以该项目为例,可以在 “BenchmarkSample ->
BenchmarkSample ->
BenchmarkDotNet.Artifacts” 文件夹中查看运行日志,运行结果在 results 目录下。
2 BenchmarkDotNet 入门
以下内容参考视频 BenchmarkDotNet简易入门指南,感谢 十月的寒流。
2.1 基础知识
- 需使用编译器优化的 Release 模式。
- 测试类、测试方法必须为 public。
- 为方法添加
[Benchmark]
特性,来标识想要测试的方法。 - Benchmark 运行时通过反射读取测试代码,额外开启一些进程运行。
- 在测试方法中,尽量避免被 JIT 优化掉的情况。
- 例如:声明一个未被使用的变量,则 Release 模式下该声明会被优化掉。
- 解决方法:将该变量作为方法返回值进行返回。
- 建议:测试方法尽量都给予返回值。
- 一般不必创建过大的数组,除非想测试内存读取等。
- 原因:测试目标一般是 CPU 运行算法的效率,而不是 IO 内存读取。
注意:
- 以下案例测试结果均为 .Net 8.0 下。
- 运行环境:
- BenchmarkDotNet:v0.13.12;
- Windows 11;
- CPU:Intel Core i7-12700H。
2.2 案例:List 排序
创建 ListOrderTest.cs 脚本,编写测试案例。将 1.3 节中的 Main() 函数内容移到 Program.cs 中。
选取以下 3 种方式比较数组排序方法的运行效率:
- 使用
List.Sort()
直接排序; - 使用
Linq.OrderBy()
排序,比较方法为 int 本身的值大小; - 使用
Linq.Order()
排序。
测试方法如下:
// See https://aka.ms/new-console-template for more information
using BenchmarkDotNet.Attributes;
public class ListOrderTest
{
private List<int> _testList;
[Benchmark]
public List<int> ListSort() {
var list = new List<int>(_testList);
list.Sort();
return list;
}
[Benchmark]
public List<int> LinqOderBy() {
return _testList.OrderBy(x => x).ToList();
}
[Benchmark]
public List<int> LinqOder() {
return _testList.Order().ToList();
}
}
2.2.1 [GlobalSetup] 测试初始化
使用 [GlobalSetup]
特性标注一个方法为测试开始前的初始化方法。本案例中,对 _testList 进行初始化,包含 100 个 0 ~ 100 的随机数。
[GlobalSetup] // 指定测试开始前的初始化方法
public void Setup() {
_testList = new List<int>();
var random = new Random();
for (int i = 0; i < 100; i++) {
_testList.Add(random.Next(0, 100));
}
}
2.2.2 [Benchmark(Baseline = true)] 指定基准方法
为 [Benchmark]
特性指定 Baseline = true
特性指定 LinqOderBy() 方法为比较基准,其他两个方法都和该方法进行比较。
[Benchmark(Baseline = true)]
public List<int> LinqOderBy() {
return _testList.OrderBy(x => x).ToList();
}
2.2.3 [MemoryDiagnoser] 显示内存分配
为测试类 ListOrderTest 添加 [MemoryDiagnoser]
特性,来显示测试方法的内存分配情况。
// See https://aka.ms/new-console-template for more information
using BenchmarkDotNet.Attributes;
[MemoryDiagnoser] // 显示内存分配
public class ListOrderTest
{
...
}
2.2.4 结果分析
可以看出,ListSort() 和 LinqOder() 的效率几乎一致,远远领先 LinqOderBy() 方法。
- 由于指定了基准方法,因此 Ratio 和 Alloc Ratio 两栏中,LinqOderBy() 值均为 1。
[MemoryDiagnoser]
添加了 Gen0、Allocated 和 Alloc Ratio 三栏,但 Gen0 一般不常用,因此可以写成[MemoryDiagnoser(false)]
以隐藏。
完整代码:
namespace BenchmarkSample.Benchmark;
using BenchmarkDotNet.Attributes;
[MemoryDiagnoser(false)] // 显示内存分配
public class ListOrderTest
{
private List<int> _testList;
[GlobalSetup] // 指定测试开始前的初始化方法
public void Setup() {
_testList = new List<int>();
var random = new Random();
for (int i = 0; i < 100; i++) {
_testList.Add(random.Next(0, 100));
}
}
[Benchmark]
public List<int> ListSort() {
var list = new List<int>(_testList);
list.Sort();
return list;
}
[Benchmark(Baseline = true)]
public List<int> LinqOderBy() {
return _testList.OrderBy(x => x).ToList();
}
[Benchmark]
public List<int> LinqOder() {
return _testList.Order().ToList();
}
}
2.3 案例:指定 List 容量初始化
我们知道,List 具有扩容机制。因此,本案例比较创建 List 时,是否指定容量(capacity)对创建效率的影响。
选取 3 种方法进行比较:
- 初始化时,不指定 capacity(默认 capacity 为 4);
- 初始化时,指定 capacity;
- 使用 Linq 中的
Enumerable.Range()
方法初始化。
测试方法如下:
namespace BenchmarkSample.Benchmark;
using BenchmarkDotNet.Attributes;
[MemoryDiagnoser(false)]
public class ListCapacityInitTest
{
public int Count;
[Benchmark(Baseline = true)]
public List<int> WithoutInit() {
var res = new List<int>();
for (int i = 0; i < Count; i++) {
res.Add(i);
}
return res;
}
[Benchmark]
public List<int> WithInit() {
var res = new List<int>(Count);
for (int i = 0; i < Count; i++) {
res.Add(i);
}
return res;
}
[Benchmark]
public List<int> WithLinq() {
return Enumerable.Range(0, Count).ToList();
}
}
2.3.1 [Params] 多值测试
为 Count 添加属性 [Params]
,初始化为 3 个值,分别进行测试比较。
[Params(4, 16, 130)] // 指定测试参数
public int Count;
2.3.2 结果分析
可以看出,指定 capacity 对于容量大的 List 而言运行速度更快,因为不需要频繁地进行扩容。
而使用 Linq 方法则更有普适性,推荐使用该方法初始化 List。
完整代码:
namespace BenchmarkSample.Benchmark;
using BenchmarkDotNet.Attributes;
[MemoryDiagnoser(false)]
public class ListCapacityInitTest
{
[Params(4, 16, 130)] // 指定测试参数
public int Count;
[Benchmark(Baseline = true)]
public List<int> WithoutInit() {
var res = new List<int>();
for (int i = 0; i < Count; i++) {
res.Add(i);
}
return res;
}
[Benchmark]
public List<int> WithInit() {
var res = new List<int>(Count);
for (int i = 0; i < Count; i++) {
res.Add(i);
}
return res;
}
[Benchmark]
public List<int> WithLinq() {
return Enumerable.Range(0, Count).ToList();
}
}
2.4 案例:翻转字符串
对比翻转字符串的 3 种方式:
- 使用 Linq 中
string.Reverse().ToArray()
翻转为字符数组,再创建新 string; - 使用 StringBuilder 辅助,手动翻转;
- 使用
string.ToCharArray()
转为字符数组,翻转后再创建新 string。
测试方法如下:
namespace BenchmarkSample.Benchmark;
using System.Text;
using BenchmarkDotNet.Attributes;
[MemoryDiagnoser(false)]
public class StringReverseTest
{
private string _testStr = "Hello World!";
[Benchmark]
public string Linq() {
return new string(_testStr.Reverse().ToArray());
}
[Benchmark]
public string StringBuilder() {
var sb = new StringBuilder();
for (int i = _testStr.Length - 1; i >= 0; i--) {
sb.Append(_testStr[i]);
}
return sb.ToString();
}
[Benchmark]
public string CharArray() {
var array = _testStr.ToCharArray();
Array.Reverse(array);
return new string(array);
}
}
2.4.1 [Orderer] [RankColumn] 结果排序显示
为测试类 StringReverseTest 添加特性 [Orderer]
以将结果排序显示,通过指定参数来选择排序方式。
同时,添加特性 [RankColumn]
以显示排名列。
[MemoryDiagnoser(false)]
[Orderer(SummaryOrderPolicy.FastestToSlowest)] // 结果排序显示,从快到慢
[RankColumn] // 显示排名列
public class StringReverseTest
{
...
}
2.4.2 结果分析
可以看出,string.ToCharArray()
方法效率最高,但一般推荐使用 StringBuilder,因为性能足够,且更为泛用。不出意料,Linq 方法效率最低。
完整代码:
namespace BenchmarkSample.Benchmark;
using System.Text;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Order;
[MemoryDiagnoser(false)]
[Orderer(SummaryOrderPolicy.FastestToSlowest)] // 结果排序显示,从快到慢
[RankColumn] // 显示排名列
public class StringReverseTest
{
private string _testStr = "Hello World!";
[Benchmark]
public string Linq() {
return new string(_testStr.Reverse().ToArray());
}
[Benchmark]
public string StringBuilder() {
var sb = new StringBuilder();
for (int i = _testStr.Length - 1; i >= 0; i--) {
sb.Append(_testStr[i]);
}
return sb.ToString();
}
[Benchmark]
public string CharArray() {
var array = _testStr.ToCharArray();
Array.Reverse(array);
return new string(array);
}
}
3 其他 Tip
3.1 [SimpleJob] 不同 .Net 版本测试
使用特性 [SimpleJob]
指定比较的版本。
[MemoryDiagnoser(false)]
[SimpleJob(RuntimeMoniker.Net70)] // 比较 .Net7.0 和 8.0
[SimpleJob(RuntimeMoniker.Net80)]
public class CSharpVersionTest
{
...
}
3.2 [IterationSetup] 迭代初始化
每次迭代前,使用特性 [IterationSetup]
指定初始化方法。
[IterationSetup]
public void IterationSetup() { }
3.3 [Arguments] 传参测试
使用特性 [Arguments]
为有参方法传递测试参数。
一次 [Arguments]
即测试一组样例,可与 [Params]
排列进行组合测试。
[Benchmark]
[Arguments(100, 0.5f)]
[Arguments(200, 2.5f)]
public void ArgumentsFunc(int a, float b) { }