大家好!今天我想和大家分享一些 .Net 5 性能技巧与基准测试!
我的系统:
- BenchmarkDotNet=v0.13.0, OS=Windows 10.0.19042.985 (20H2/October2020Update)
- Intel Core i7-9750H CPU 2.60GHz,1 个 CPU,12 个逻辑内核和 6 个物理内核
- .NET SDK=5.0.104
我将以 100% 是最快结果的百分比提供基准测试结果。
1.用于连接的StringBuilder
您可能知道,字符串是不可变的。因此,每当您连接字符串时,都会分配一个新的字符串对象,填充内容,并最终收集垃圾。所有这些都是昂贵的,这就是为什么 StringBuilder 将始终具有更好的性能。
基准示例:
private static StringBuilder sb = new();
[Benchmark]
public void Concat3() => ExecuteConcat(3);
[Benchmark]
public void Concat5() => ExecuteConcat(5);
[Benchmark]
public void Concat10() => ExecuteConcat(10);
[Benchmark]
public void Concat100() => ExecuteConcat(100);
[Benchmark]
public void Concat1000() => ExecuteConcat(1000);
[Benchmark]
public void Builder3() => ExecuteBuilder(3);
[Benchmark]
public void Builder5() => ExecuteBuilder(5);
[Benchmark]
public void Builder10() => ExecuteBuilder(10);
[Benchmark]
public void Builder100() => ExecuteBuilder(100);
[Benchmark]
public void Builder1000() => ExecuteBuilder(1000);
public void ExecuteConcat(int size)
{
string s = "";
for (int i = 0; i < size; i++)
{
s += "a";
}
}
public void ExecuteBuilder(int size)
{
sb.Clear();
for (int i = 0; i < size; i++)
{
sb.Append("a");
}
}
结果:
- 3 个字符串连接 – 218% (35.21 ns)
- 3 个 StringBuilder 连接 – 100% (16.09 ns)
- 5 个字符串连接 – 277% (66.99 ns)
- 5 个 StringBuilder 连接 – 100% (24.16 ns)
- 10 个字符串连接 – 379% (160.69 ns)
- 10 个 StringBuilder 连接 – 100% (42.37 ns)
- 100 个字符串连接 – 711% (2,796.63 ns)
- 100 个 StringBuilder 连接 – 100% (393.12 ns)
- 1000 个字符串连接 – 3800% (144,100.46 ns)
- 1000 个 StringBuilder 连接 – 100% (3,812.22 ns)
2. 动态集合的初始大小
.NET 提供了很多集合,例如 List<T>、Dictionary<T> 和 HashSet<T>。所有这些集合都具有动态大小容量。当您添加更多项目时,它们会自动扩大其大小。
当集合达到它的大小限制时,它将分配一个新的更大的内存缓冲区(通常是一个大小翻倍的数组)。这意味着额外的分配和释放。
基准示例:
[Benchmark]
public void ListDynamicCapacity()
{
List<int> list = new List<int>();
for (int i = 0; i < Size; i++)
{
list.Add(i);
}
}
[Benchmark]
public void ListPlannedCapacity()
{
List<int> list = new List<int>(Size);
for (int i = 0; i < Size; i++)
{
list.Add(i);
}
}
在第一种方法中,List 集合以默认容量开始并扩展大小。在第二个基准测试中,初始容量设置为它将拥有的项目数。
对于 1000 个项目,结果是:
- 列出动态容量 – 140% (2.490 us)
- 列出计划容量 – 100% (1.774 us)
Dictionary 和 HashSet 的基准测试:
- 字典动态容量 – 233% (20.314 us)
- 字典计划容量 – 100% (8.702 us)
- HashSet 动态容量 – 223% (17.004 us)
- HashSet 计划容量 – 100% (7.624 us)
3. ArrayPool 用于短命大数组
阵列的分配和不可避免的解除分配可能非常昂贵。以高频率执行这些分配将导致 GC 压力并损害性能。一个优雅的解决方案是在 Systems.Buffers NuGet中找到的 System.Buffers.ArrayPool 类 。
这个想法与 ThreadPool 非常相似。为数组分配了一个共享缓冲区,您可以在不实际分配和取消分配内存的情况下重复使用该缓冲区。基本用法是调用 ArrayPool<T>.Shared.Rent(size)。这将返回一个常规数组,您可以随意使用它。完成后,调用 ArrayPool<int>.Shared.Return(array) 将缓冲区返回到共享池。
基准示例:
[Benchmark]
public void RegularArray()
{
int[] array = new int[ArraySize];
}
[Benchmark]
public void SharedArrayPool()
{
var pool = ArrayPool<int>.Shared;
int[] array = pool.Rent(ArraySize);
pool.Return(array);
}
ArraySize = 1000 的结果:
- 常规阵列 – 2270% (440.41 ns)
- 共享 ArrayPool – 100% (19.40 ns)
4.结构而不是类
在解除分配方面,结构有几个好处:
- 当结构不是类的一部分时,它们被分配在堆栈上,根本不需要垃圾收集。
- 当结构是类(或任何引用类型)的一部分时,结构将存储在堆上。在这种情况下,它们被内联存储并在包含类型被释放时被释放。内联意味着结构的数据按原样存储。与引用类型相反,其中指针与实际数据一起存储到堆上的另一个位置。这在集合中特别有意义,其中结构集合的释放成本要低得多,因为它只是一个内存缓冲区。
- 结构比引用类型占用更少的内存,因为它们没有 ObjectHeader 和 MethodTable。
根据指南决定是否使用 struct 。
基准示例:
class VectorClass
{
public int X { get; set; }
public int Y { get; set; }
}
struct VectorStruct
{
public int X { get; set; }
public int Y { get; set; }
}
private const int ITEMS = 10000;
[Benchmark]
public void WithClass()
{
VectorClass[] vectors = new VectorClass[ITEMS];
for (int i = 0; i < ITEMS; i++)
{
vectors[i] = new VectorClass();
vectors[i].X = 5;
vectors[i].Y = 10;
}
}
[Benchmark]
public void WithStruct()
{
VectorStruct[] vectors = new VectorStruct[ITEMS];
// At this point all the vectors instances are already allocated with default values
for (int i = 0; i < ITEMS; i++)
{
vectors[i].X = 5;
vectors[i].Y = 10;
}
}
结果:
- 有类 – 742% (88.83 us)
- 带结构 – 100% (11.97 us)
5. StackAlloc 用于短期数组分配
C# 中的 StackAlloc 关键字允许非常快速地分配和释放非托管内存。也就是说,类不起作用,但支持原语、结构和数组。
基准示例:
struct VectorStruct
{
public int X { get; set; }
public int Y { get; set; }
}
[Benchmark]
public void WithNew()
{
VectorStruct[] vectors = new VectorStruct[5];
for (int i = 0; i < 5; i++)
{
vectors[i].X = 5;
vectors[i].Y = 10;
}
}
[Benchmark]
public unsafe void WithStackAlloc() // Note that unsafe context is required
{
VectorStruct* vectors = stackalloc VectorStruct[5];
for (int i = 0; i < 5; i++)
{
vectors[i].X = 5;
vectors[i].Y = 10;
}
}
[Benchmark]
public void WithStackAllocSpan() // When using Span, no need for unsafe context
{
Span<VectorStruct> vectors = stackalloc VectorStruct[5];
for (int i = 0; i < 5; i++)
{
vectors[i].X = 5;
vectors[i].Y = 10;
}
}
结果:
- 新 – 303% (10.870 ns)
- 使用 StackAlloc – 102% (3.643 ns)
- 使用 StackAllocSpan – 100% (3.580 ns)
6. ConcurrentQueue<T> 代替 ConcurrentBag<T>
切勿在未进行基准测试的情况下使用 ConcurrentBag<T>。这个集合是为非常特定的用例而设计的(大多数情况下,一个项目被加入队列的线程出队),如果以其他方式使用,则会出现重要的性能问题。如果需要并发集合,则首选 ConcurrentQueue<T>。
基准示例:
private static int Size = 1000;
[Benchmark]
public void Bag()
{
ConcurrentBag<int> bag = new();
for (int i = 0; i < Size; i++)
{
bag.Add(i);
}
}
[Benchmark]
public void Queue()
{
ConcurrentQueue<int> bag = new();
for (int i = 0; i < Size; i++)
{
bag.Enqueue(i);
}
}
结果:
- ConcurrentBag – 165% (24.21 us)
- 并发队列 – 100% (14.64 us)