記憶體最佳化:Boxing

xiaolipro發表於2024-06-06

dotMemory

如今,許多開發人員都熟悉效能分析的工作流程:在分析器下執行應用程式,測量方法的執行時間,識別佔用時間較多的方法,並致力於最佳化它們。然而,這種情況並沒有涵蓋到一個重要的效能指標:應用程式多次GC所分配的時間。當然,你可以評估GC所需的總時間,但是它從哪裡來,如何減少呢? “普通”效能分析不會給你任何線索。

垃圾收集總是由高記憶體流量引起的:分配的記憶體越多,需要收集的記憶體就越多。眾所周知,記憶體流量最佳化應該在記憶體分析器的幫助下完成。它允許你確定物件是如何分配和收集的,以及這些分配背後保留了哪些方法。理論上看起來很簡單,對吧?然而,在實踐中,許多開發人員最終都會這樣說:“好吧,我的應用程式中的一些流量是由一些系統類生成的,這些系統類的名稱是我一生中第一次看到的。我想這可能是因為一些糟糕的程式碼設計。現在我該怎麼做?”

這就是這篇文章的主題。實際上,這將是一系列文章,我將在其中分享我的記憶體流量分析經驗:我認為什麼是“糟糕的程式碼設計”,如何在記憶體中找到其蹤跡,當然還有我認為的最佳實踐。

簡單的例子:如果您在堆中看到值型別的物件,那麼裝箱肯定是罪魁禍首。裝箱總是意味著額外的記憶體分配,因此移除它很可能會讓您的應用程式變得更好。

該系列的第一篇文章將重點關注裝箱。如果檢測到“bad memory pattern”,該去哪裡查詢以及如何採取行動?

本系列中描述的最佳實踐使我們能夠將 .NET 產品中某些演算法的效能提高 20%-50%。

您需要什麼工具

在我們進一步討論之前,先看看我們需要的工具。我們在 JetBrains 使用的工具列表非常簡短:

  • dotMemory 記憶體分析器。無論您試圖查詢什麼問題,分析演算法始終相同:
    • 在啟用記憶體流量收集的情況下開始分析您的應用程式。
    • 在您感興趣的方法或功能完成工作後收集記憶體快照。
    • 開啟快照並選擇記憶體流量檢視。
  • Heap Allocations Viewer外掛。該外掛會突出顯示程式碼中分配記憶體的所有位置。這不是必須的,但它使編碼更加方便,並且在某種意義上“迫使”您避免過度分配。

Boxing

裝箱是將值型別轉換為引用型別。 例如:

int i = 5;
object o = i; // 發生裝箱

為什麼這是個問題?值型別儲存在棧中,而引用型別儲存在託管堆中。因此,要將整數值分配給物件,CLR 必須從棧中取出該值並將其複製到堆中。當然,這種移動會影響應用程式的效能。

一個物件的至少佔用3個指標單元:物件頭(object header)、方法表指標(method table ref)、預留單元(首欄位地址/陣列長度)

在x64系統3個指標單元意味24位元組的開銷,而一個int型別本身只佔用4位元組,其次,棧記憶體的由執行執行緒方法棧管理,方法內宣告的local變數、字面量更是能夠在IL編譯期就預算出棧容量,效率遠高於執行時堆記憶體GC體系

如何發現

使用 dotMemory,找到boxing是一項基本任務:

  1. 開啟View memory allocations檢視。
  2. 查詢值型別的物件(Group by Types),這些都是boxing的結果。
  3. 確定分配這些物件並生成大部分流量的方法。

當我們嘗試將值型別賦值給引用型別時,Heap Allocation Viewer外掛也會提示閉包分配的事實:

​ Boxing allocation: conversion from value type 'int' to reference type 'object'

從效能角度來看,您更感興趣的是這種閉包發生的頻率。例如,如果帶有裝箱分配的程式碼只被呼叫一次,那麼最佳化它不會有太大幫助。考慮到這一點,dotMemory 在檢測閉包是否引起真正問題方面要可靠得多。

如何修復

在解決裝箱問題之前,請確保它確實會產生大量流量。如果是這樣,你的任務就很明確:重寫程式碼以消除裝箱。當你引入某些值型別時,請確保不會在程式碼中的任何位置將值型別轉換為引用型別。例如,一個常見的錯誤是將值型別的變數傳遞給使用字串的方法(例如 String.Format):

int i = 5;
string.Format("i = {0}", i); // 引發box

一個簡單的修復方法是呼叫恰當的值型別 ToString() 方法:

int i = 5;
string.Format("i = {0}", i.ToString());

Resize Collections

動態大小的集合(例如 Dictionary, List, HashSet, 和 StringBuilder )具有以下特性: 當集合大小超過當前邊界時,.NET 會調整集合的大小並在記憶體中重新定義整個集合。顯然,如果這種情況頻繁發生,應用程式的效能將會受到影響。

如何發現

使用 dotMemory 比對兩個快照

  1. 開啟View memory allocations檢視

  2. 找到產生大記憶體流量的集合型別

  3. 看看是否與 Dictionary<>.ResizeList<>.SetCapacityStringBuilder.ExpandByABlock等等集合擴容有關

如何修復

如果“resize”方法造成的流量很大,唯一的解決方案是減少需要調整大小的情況數量。嘗試預測所需的大小並用該大小初始化集合。

var list = new List<string>(1000); // 初始容量1000

此外請記住,任何大於或等於 85,000 位元組的分配都會在大物件堆 (LOH) 上進行。在 LOH 中分配記憶體會帶來一些效能損失:由於 LOH 未壓縮,因此在分配時需要 CLR 和空閒列表之間進行一些額外的互動。然而,在某些情況下,在 LOH 中分配物件是有意義的,例如,在必須承受應用程式的整個生命週期的大型集合(例如快取)的情況下。

Enumerating Collections

使用動態集合時,請注意列舉它們的方式。這裡典型的主要頭痛是使用 foreach 列舉一個集合,只知道它實現了 IEnumerable 介面。考慮以下示例:

class EnumerableTest
{
	private void Foo(IEnumerable<string> sList)
    {
		foreach (var s in sList)
        {
            
		}
	}
	public void Goo()
    {
		var list = new List<string>();
		for (int i = 0; i < 1000; i++)
        {
			Foo(list);
		}
    }
}

Foo 方法中的列表被轉換為 IEnumerable 介面,這意味著列舉器的進一步裝箱,因為List<T>.Enumerator是結構體。

public struct Enumerator : IEnumerator<T>, IEnumerator, IDisposable
{
    public T Current { get; }

    object IEnumerator.Current { get; }

    public void Dispose();

    public bool MoveNext();

    void IEnumerator.Reset();
}

如何發現

  1. 開啟View memory allocations檢視
  2. 找到值型別System.Collections.Generic.List+Enumerator並檢查生成的流量。
  3. 查詢生成這些物件的方法。
  4. Heap Allocation Viewer外掛也會提示您有關隱藏分配的資訊:

如何修復

避免將集合強制轉換為介面。在上面的示例中,最佳解決方案是建立一個接受 List<string> 集合的 Foo 方法過載。

private void Foo(List<string> sList)
{
    foreach (var s in sList)
    {
        
    }
}

如果我們在修復後分析程式碼,會發現 Foo 方法不再建立列舉器。

don’t prematurely optimize

易讀性應該在多數時候成為我們編碼的第一原則,而非的效能優先或記憶體優先。本文討論的一切都是微觀最佳化,定期進行記憶體分析是良好的習慣

例如,交換a和b,從第一直覺上我們會編寫出以下程式碼:

int a = 5;
int b = 10;

var temp = a;
a = b;
b = temp;

// 在c# 7+我們甚至可以用元組,進一步增強可閱讀性
(a, b) = (b, a);

但是下面這種寫法透過按位運算,可以不必申請額外空間來儲存temp

a = a ^ b;
b = a ^ b;
a = a ^ b;

但這並不是我們鼓勵的:過早的在編碼初期進行最佳化,喪失可讀性。在99%的情況下,我們的程式碼應該只依賴語義,剩下的,交給探查器!

上文Boxing提到的string.Format案例,只能代表今天,而不是明天。也許下一個將在IL編譯時甚至JIT中去解決值型別裝箱問題,Enumerating Collections也是同一個道理。

int i = 5;
string.Format("i = {0}", i); // 引發box

DefaultInterpolatedStringHandler

.net6引入的ref結構DefaultInterpolatedStringHandler,就是一個很好的案例

$"..." 這種字串插值(String Interpolation)語法是在 C# 6.0 中引入的。

var i = 5;
var str = $"i = {i}"; // box

在.net6之前,上面的寫法會發生裝箱,生成的IL如下:

IL_001a: ldarg.0      // this
IL_001b: ldstr        "i = {0}"
IL_0020: ldarg.0      // this
IL_0021: ldfld        int32 Fake.EventBus.RabbitMQ.RabbitMqEventBus/'<ProcessingEventAsync>d__19'::'<i>5__1'
IL_0026: box          [netstandard]System.Int32
IL_002b: call         string [netstandard]System.String::Format(string, object)
IL_0030: stfld        string Fake.EventBus.RabbitMQ.RabbitMqEventBus/'<ProcessingEventAsync>d__19'::'<str>5__2'

而從.net6開始,生成的IL發生了變化,由原來呼叫的System.String::Format(string, object),變成了DefaultInterpolatedStringHandler,裝箱也不見了,內部細節感興趣的自己去閱讀原始碼,內部用到了高效能的Span,unsafe和ArrayPool

IL_0014: ldloca.s     V_3
IL_0016: ldc.i4.4
IL_0017: ldc.i4.1
IL_0018: call         instance void [System.Runtime]System.Runtime.CompilerServices.DefaultInterpolatedStringHandler::.ctor(int32, int32)
IL_001d: ldloca.s     V_3
IL_001f: ldstr        "i = "
IL_0024: call         instance void [System.Runtime]System.Runtime.CompilerServices.DefaultInterpolatedStringHandler::AppendLiteral(string)
IL_0029: nop
IL_002a: ldloca.s     V_3
IL_002c: ldloc.0      // i
IL_002d: call         instance void [System.Runtime]System.Runtime.CompilerServices.DefaultInterpolatedStringHandler::AppendFormatted<int32>(!!0/*int32*/)
IL_0032: nop
IL_0033: ldloca.s     V_3
IL_0035: call         instance string [System.Runtime]System.Runtime.CompilerServices.DefaultInterpolatedStringHandler::ToStringAndClear()
IL_003a: stloc.1      // str

不要過早最佳化

不要過早最佳化!!!

不要過早最佳化!!!

不要過早最佳化!!!

本系列參考jetbrains官方團隊的部落格:https://blog.jetbrains.com/dotnet,加以作者的個人理解做出的二次創作,如有侵權請聯絡刪除:2357729423@qq.com。

相關文章