五個 .NET 效能小貼士

精緻碼農發表於2021-07-28

原文:bit.ly/3wSpO4o
作者:Nikita Starichenko
翻譯:精緻碼農

大家好!今天我想和大家分享幾個 .NET 的效能小貼士與基準測試。

我的系統環境:

  • BenchmarkDotNet=v0.13.0, OS=Windows 10.0.19042.985
  • Intel Core i7-9750H CPU 2.60GHz, 1 CPU, 12 logical and 6 physical cores
  • .NET SDK=5.0.104

我將以百分比的形式提供基準測試結果,其中 100% 是最快的結果。

用 StringBuilder 拼接字串

我們知道,字串 string 是不可變的。因此,每當你拼接字串時,就會分配一個新的字串物件,並填充內容,最終被回收。所有這些都有昂貴開銷,這就是為什麼 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");
    }
}

結果:

1. 3 string concatenations - 218% (35.21 ns)
2. 3 StringBuilder concatenations - 100% (16.09 ns)

1. 5 string concatenations - 277% (66.99 ns)
2. 5 StringBuilder concatenations - 100% (24.16 ns)

1. 10 string concatenations - 379% (160.69 ns)
2. 10 StringBuilder concatenations - 100% (42.37 ns)

1. 100 string concatenations - 711% (2,796.63 ns)
2. 100 StringBuilder concatenations - 100% (393.12 ns)

1. 1000 string concatenations - 3800% (144,100.46 ns)
2. 1000 StringBuilder concatenations - 100% (3,812.22 ns)

賦予動態集合初始大小

.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 個專案,其結果是:

1. List Dynamic Capacity - 140% (2.490 us)
2. List Planned Capacity - 100% (1.774 us)

DictionaryHashSet 的測試結果是:

1. Dictionary Dynamic Capacity - 233% (20.314 us)
2. Dictionary Planned Capacity - 100% (8.702 us)

1. HashSet Dynamic Capacity - 223% (17.004 us)
2. HashSet Planned Capacity - 100% (7.624 us)

ArrayPool 用於短時大陣列

陣列的分配和回收的開銷可能是相當昂貴的,高頻地執行這些分配會增加 GC 的壓力並損害效能。一個優雅的解決方案使用是 System.Buffers.ArrayPool 類,它可以在 NuGet 的 Systems.Buffers 中找到。

這個思想和 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 的結果:

1. Regular Array - 2270% (440.41 ns)
2. Shared ArrayPool - 100% (19.40 ns)

結構代替類

當涉及到物件回收時,Struct 有如下幾個好處:

  • 當結構型別不是類的一部分時,它們被分配在堆疊中,根本不需要垃圾回收。
  • 當結構是類(或任何引用型別)的一部分時,它們被儲存在堆中。在這種情況下,它們是內聯儲存的,並且會隨包含型別回收而回收。內聯意味著該結構的資料是按原樣儲存的,這與引用型別相反,在引用型別中,指標被儲存到堆上另一個位置。所以回收的成本要低很多。
  • 結構比引用型別佔用的記憶體更少,因為它們沒有 ObjectHeaderMethodTable

基準測試例子:

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;
    }
}

結果:

1. With Class - 742% (88.83 us)
2. With Struct - 100% (11.97 us)

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);
    }
}

結果:

1. ConcurrentBag - 165% (24.21 us)
2. ConcurrentQueue - 100% (14.64 us)

感謝大家閱讀!

相關文章