.NET效能最佳化:使用結構體替代類別

.NET效能最佳化:使用結構體替代類別

我們知道在C#和Java明顯的一個區別就是C#可以自訂值型別,也就是今天的主角struct,我們有了更加方便的class為什麼微軟還加入了struct呢?

最後更新 2022/5/5 下午9:33
InCerry
預計閱讀 23 分鐘
分類
.NET
標籤
.NET C# Java 效能最佳化

作者:InCerry

出處:https://www.cnblogs.com/InCerry/archive/2022/05/05/Dotnet-Opt-Perf-Use-Struct-Instead-Of-Class.html

版權:本作品採用「署名-非商業性使用-相同方式共享 4.0 國際」許可協定進行許可。

聲明:本部落格版權歸「InCerry」所有。

1. 前言

我們知道在 C# 和 Java 明顯的一個區別就是 C# 可以自訂實值型別,也就是今天的主角 struct,我們有了更加方便的 class 為什麼微軟還加入了 struct 呢?這其實就是今天要談到的一個最佳化效能的 Tips 使用結構體替代類別

那麼使用結構體替代類別有什麼好處呢?在什麼樣的場景需要使用結構體來替代類別呢?今天的文章為大家一一解答。

注意:本文全部都以 x64 位平台為例

2. 現實的案例

舉一個現實系統的例子,大家都知道機票購票的流程,開始選擇起抵城市和機場(這是航線),然後根據自己的需要日期和時間,挑一個自己喜歡的航班和艙位,然後付款。

2.1 記憶體佔用

那麼全國大約 49 航司,8000 多個航線,平均每個航線有 20 個航班,每個航班平均有 10 組艙位價格(經濟艙、頭等還有不同的折扣權益),一般 OTA(Online Travel Agency:線上旅遊平台)允許預訂一年內的機票。也就是說平台可能有 8000*20*10*365=~5億 的價格數據(以上數據皆來源網路,實際中的數據量不方便透露)。

OTA 平台為了能讓你更快的搜尋想要的航班,會將熱門的航線價格數據從資料庫拿出來快取在記憶體中(記憶體比單獨網路和磁碟傳輸快的多得多,詳情見下圖),就取 20% 也大約有 1 億數據在記憶體中。

操作 速度
執行指令 1/1,000,000,000 秒 = 1 奈秒
從一級快取讀取數據 0.5 奈秒
分支預測失敗 5 奈秒
從二級快取讀取數據 7 奈秒
使用 Mutex 加鎖和解鎖 25 奈秒
從主存(RAM 記憶體)中讀取數據 100 奈秒
在 1Gbps 速率的網路上傳送 2Kbyte 的數據 20,000 奈秒
從記憶體中讀取 1MB 的數據 250,000 奈秒
磁頭移動到新的位置(代指機械硬碟) 8,000,000 奈秒
從磁碟中讀取 1MB 的數據 20,000,000 奈秒
發送一個數據封包從美國到歐洲然後回來 150 毫秒 = 150,000,000 奈秒

假設我們有如下一個類別,類別裡面有這些屬性(現實中要複雜的多,而且會分航線、日期等各個維度儲存,而且不同航班有不同的販售規則,這裡演示方便忽略),那麼這 1 億數據快取在記憶體中需要多少空間呢?

public class FlightPriceClass
{
    /// <summary>
    /// 航司二字碼 如 中國國際航空股份有限公司:CA
    /// </summary>
    public string Airline { get; set; }

    /// <summary>
    /// 起始機場三字碼 如 上海虹橋國際機場:SHA
    /// </summary>
    public string Start { get; set; }

    /// <summary>
    /// 抵達機場三字碼 如 北京首都國際機場:PEK
    /// </summary>
    public string End { get; set; }

    /// <summary>
    /// 航班號 如 CA0001
    /// </summary>
    public string FlightNo { get; set; }

    /// <summary>
    /// 艙位代碼 如 Y
    /// </summary>
    public string Cabin { get; set; }

    /// <summary>
    /// 價格 單位:元
    /// </summary>
    public decimal Price { get; set; }

    /// <summary>
    /// 起飛日期 如 2017-01-01
    /// </summary>
    public DateOnly DepDate { get; set; }

    /// <summary>
    /// 起飛時間 如 08:00
    /// </summary>
    public TimeOnly DepTime { get; set; }

    /// <summary>
    /// 抵達日期 如 2017-01-01
    /// </summary>
    public DateOnly ArrDate { get; set; }

    /// <summary>
    /// 抵達時間 如 08:00
    /// </summary>
    public TimeOnly ArrTime { get; set; }
}

我們可以寫一個 Benchmark,來看看 100W 的數據需要多少空間,然後在推導出 1 億的數據

// 隨機預先生成100W的數據 避免計算邏輯導致結果不準確
public static readonly FlightPriceClass[] FlightPrices = Enumerable.Range(0,
        100_0000
    ).Select(index =>
        new FlightPriceClass
        {
            Airline = $"C{(char)(index % 26 + 'A')}",
            Start = $"SH{(char)(index % 26 + 'A')}",
            End = $"PE{(char)(index % 26 + 'A')}",
            FlightNo = $"{index % 1000:0000}",
            Cabin = $"{(char)(index % 26 + 'A')}",
            Price = index % 1000,
            DepDate = DateOnly.FromDateTime(BaseTime.AddHours(index)),
            DepTime = TimeOnly.FromDateTime(BaseTime.AddHours(index)),
            ArrDate = DateOnly.FromDateTime(BaseTime.AddHours(3 + index)),
            ArrTime = TimeOnly.FromDateTime(BaseTime.AddHours(3 + index)),
        }).ToArray();

// 使用類別來儲存
[Benchmakr]
public FlightPriceClass[] GetClassStore()
{
    var arrays = new FlightPriceClass[FlightPrices.Length];
    for (int i = 0; i < FlightPrices.Length; i++)
    {
        var item = FlightPrices[i];
        arrays[i] = new FlightPriceClass
        {
            Airline = item.Airline,
            Start = item.Start,
            End = item.End,
            FlightNo = item.FlightNo,
            Cabin = item.Cabin,
            Price = item.Price,
            DepDate = item.DepDate,
            DepTime = item.DepTime,
            ArrDate = item.ArrDate,
            ArrTime = item.ArrTime
        };
    }
    return arrays;
}

來看看最終的結果,圖片如下所示。

從上面的圖可以看出來 100W 數據大約需要 107MB 的記憶體儲存,那麼一個佔用物件大約就是 112byte 了,那麼一億的物件就是約等於 10.4GB。這個大小已經比較大了,那麼還有沒有更多的方案可以減少一些記憶體佔用呢?有小夥伴就說了一些方案。

  • 可以用 int 來編號字串
  • 可以使用 long 來儲存時間戳
  • 可以想辦法用 zip 之類演算法壓縮一下
  • 等等

我們暫時也不用這些方法,對照本文的的標題,大家應該能想到用什麼辦法,嘿嘿,那就是使用結構體來替代類別,我們定義了一個一樣的結構體,如下所示。

[StructLayout(LayoutKind.Auto)]
public struct FlightPriceStruct
{
    // 屬性與類別一致
    ......
}

我們可以使用 Unsafe.SizeOf 來查看實值型別所需要的記憶體大小,比如像下面這樣。

可以看到這個結構體只需要 88byte,比類別所需要的 112byte 少了 27%。來實際看看能節省多少記憶體。

結果很不錯呀,記憶體確實如我們計算的一樣少了 27%,另外賦值速度快了 57%,而且更重要的是 GC 發生的次數也少了。

那麼為什麼結構體可以節省那麼多的記憶體呢?這裡需要聊一聊結構體和類別儲存數據的區別,下圖是類別陣列的儲存格式。

文章配圖-類別.drawio

我們可以看到類別陣列只存放指向陣列參考元素的指標,不直接儲存數據,而且每個參考型別的實例都有以下這些東西。

  • 物件標頭:大小為 8Byte,CoreCLR 上的描述是儲存「需要負載到物件上的所有附加資訊」,比如儲存物件的 lock 值或者 HashCode 快取值。
  • 方法表指標:大小為 8Byte,指向型別的描述數據,也就是經常提到的(Method Table),MT 裡面會存放 GCInfo,欄位以及方法定義等等。
  • 物件佔位符:大小為 8Byte,當前的 GC 要求所有的物件至少有一個當前指標大小的欄位,如果是一個空類別,除了物件標頭和方法表指標以外,還會佔用 8Byte,如果不是空類別,那就是存放第一個欄位。

也就是說一個空類別不定義任何東西,也至少需要 24byte 的空間,8byte 物件標頭+8byte 方法表指標+8byte 物件佔位符。

回到本文中,由於不是一個空類別,所以每個物件除了數據儲存外需要額外的 16byte 儲存物件標頭和方法表,另外陣列需要 8byte 存放指向物件的指標,所以一個物件儲存在陣列中需要額外佔用 24byte 的空間。我們再來看看實值型別(結構體)。

文章配圖-結構體.drawio

從上圖中,我們可以看到如果是實值型別的陣列,那麼數據是直接儲存在陣列上,不需要參考。所以儲存相同的數據,每個空結構體都能省下 24byte(無需物件標頭、方法表和指向實例的指標)。

另外結構體陣列當中的陣列,陣列也是參考型別,所以它也有 24byte 的數據,它的物件佔位符用來存放陣列型別的第一個欄位-陣列大小。

我們可以使用 ObjectLayoutInspector 這個 NuGet 套件列印物件的佈局資訊,類別定義的佈局資訊如下,可以看到除了數據儲存需要的 88byte 以外,還有 16byte 額外空間。

結構體定義的佈局資訊如下,可以看到每個結構體都是實際的數據儲存,不包含額外的佔用。

那可不可以節省更多的記憶體呢?我們知道在 64 位平台上一個參考(指標)是 8byte,而在 C# 上預設的字串使用 Unicode-16,也就是說 2byte 代表一個字元,像航司二字碼、起抵機場這些小於 4 個字元的完全可以使用 char 陣列來節省記憶體,比一個指標佔用還要少,那我們修改一下程式碼。

// 跳過區域變數初始化
[SkipLocalsInit]
// 調整佈局方式 使用Explicit自訂佈局
[StructLayout(LayoutKind.Explicit, CharSet = CharSet.Unicode)]
public struct FlightPriceStructExplicit
{
    // 需要手動指定偏移量
    [FieldOffset(0)]
    // 航司使用兩個字元儲存
    public unsafe fixed char Airline[2];

    // 由於航司使用了4byte 所以起始機場偏移4byte
    [FieldOffset(4)]
    public unsafe fixed char Start[3];

    // 同理起始機場使用6byte 偏移10byte
    [FieldOffset(10)]
    public unsafe fixed char End[3];

    [FieldOffset(16)]
    public unsafe fixed char FlightNo[4];

    [FieldOffset(24)]
    public unsafe fixed char Cabin[2];

    // decimal 16byte
    [FieldOffset(28)]
    public decimal Price;

    // DateOnly 4byte
    [FieldOffset(44)]
    public DateOnly DepDate;

    // TimeOnly 8byte
    [FieldOffset(48)]
    public TimeOnly DepTime;
    [FieldOffset(56)]
    public DateOnly ArrDate;
    [FieldOffset(60)]
    public TimeOnly ArrTime;

}

在來看看這個新結構體物件的佈局資訊。

可以看到現在只需要 68byte 了,最後 4byte 是為了位址對齊,因為 CPU 字長是 64bit,我們不用管。按照我們的計算能比 88Byte 節省了 29% 的空間。當然使用 unsafe fixed char 以後就不能直接賦值了,需要進行數據拷貝才行,程式碼如下。

// 用於設定string值的擴充方法
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static unsafe void SetTo(this string str, char* dest)
{
    fixed (char* ptr = str)
    {
        Unsafe.CopyBlock(dest, ptr, (uint)(Unsafe.SizeOf<char>() * str.Length));
    }
}

// Benchmark的方法
public static unsafe FlightPriceStructExplicit[] GetStructStoreStructExplicit()
{
    var arrays = new FlightPriceStructExplicit[FlightPrices.Length];
    for (int i = 0; i < FlightPrices.Length; i++)
    {
        ref var item = ref FlightPrices[i];
        arrays[i] = new FlightPriceStructExplicit
        {
            Price = item.Price,
            DepDate = item.DepDate,
            DepTime = item.DepTime,
            ArrDate = item.ArrDate,
            ArrTime = item.ArrTime
        };
        ref var val = ref arrays[i];
        // 需要先fixed 然後再賦值
        fixed (char* airline = val.Airline)
        fixed (char* start = val.Start)
        fixed (char* end = val.End)
        fixed (char* flightNo = val.FlightNo)
        fixed (char* cabin = val.Cabin)
        {
            item.Airline.SetTo(airline);
            item.Start.SetTo(start);
            item.End.SetTo(end);
            item.FlightNo.SetTo(flightNo);
            item.Cabin.SetTo(cabin);
        }
    }
    return arrays;
}

再來跑一下,看看這樣儲存提升是不是能節省 29% 的空間呢。

是吧,從 84MB->65MB 節省了大約 29% 的記憶體,不錯不錯,基本可以達到預期了。

但是我們發現這個 Gen0 Gen1 Gen2 這些 GC 發生了很多次,在實際中的話因為這些都是使用的受控記憶體,GC 在進行回收的時候會掃描這 65MB 的記憶體,可能會讓它的 STW 變得更久;既然這些是快取的數據,一段時間內不會回收和改變,那我們能讓 GC 別掃描這些嘛?答案是有的,我們可以直接使用非受控記憶體,使用 Marshal 類別就可以申請和管理非受控記憶體,可以達到你寫 C 語言的時候用的 malloc 函式類似的效果。

// 配置非受控記憶體
// 傳參是所需要配置的位元組數
// 回傳值是指向記憶體的指標
IntPtr Marshal.AllocHGlobal(int cb);

// 釋放配置的非受控記憶體
// 傳參是由Marshal配置記憶體的指標位址
void Marshal.FreeHGlobal(IntPtr hglobal);

再修改一下 Benchmark 的程式碼,將它改成使用非受控記憶體。

// 定義了out ptr參數,用於將指標傳回
public static unsafe int GetStructStoreUnManageMemory(out IntPtr ptr)
{
    // 使用AllocHGlobal配置記憶體,大小使用SizeOf計算結構體大小乘需要的數量
    var unManagerPtr = Marshal.AllocHGlobal(Unsafe.SizeOf<FlightPriceStructExplicit>() * FlightPrices.Length);
    ptr = unManagerPtr;
    // 將記憶體空間指派給FlightPriceStructExplicit陣列使用
    var arrays = new Span<FlightPriceStructExplicit>(unManagerPtr.ToPointer(), FlightPrices.Length);
    for (int i = 0; i < FlightPrices.Length; i++)
    {
        ref var item = ref FlightPrices[i];
        arrays[i] = new FlightPriceStructExplicit
        {
            Price = item.Price,
            DepDate = item.DepDate,
            DepTime = item.DepTime,
            ArrDate = item.ArrDate,
            ArrTime = item.ArrTime
        };
        ref var val = ref arrays[i];
        fixed (char* airline = val.Airline)
        fixed (char* start = val.Start)
        fixed (char* end = val.End)
        fixed (char* flightNo = val.FlightNo)
        fixed (char* cabin = val.Cabin)
        {
            item.Airline.SetTo(airline);
            item.Start.SetTo(start);
            item.End.SetTo(end);
            item.FlightNo.SetTo(flightNo);
            item.Cabin.SetTo(cabin);
        }
    }
    // 回傳長度
    return arrays.Length;
}

// 切記,非受控記憶體不使用的時候 需要手動釋放
[Benchmark]
public void GetStructStoreUnManageMemory()
{
    _ = FlightPriceCreate.GetStructStoreUnManageMemory(out var ptr);
    // 釋放非受控記憶體
    Marshal.FreeHGlobal(ptr);
}

再來看看 Benchmark 的結果。

結果非常 Amazing 呀,沒有在受控記憶體上配置空間,賦值的速度也比原來快了很多,後面發生 GC 的時候也無需掃描這一段記憶體,降低了 GC 壓力。這樣的結果基本就比較滿意了。

到現在的話儲存 1 億的數據差不多 6.3GB,如果使用上文中提高的其他方法,應該還能降低一些,比如像如下程式碼一樣,使用列舉來替換字串,金額使用「分」儲存,只存時間戳。

[StructLayout(LayoutKind.Explicit, CharSet = CharSet.Unicode)]
[SkipLocalsInit]
public struct FlightPriceStructExplicit
{
	// 使用byte標識航司  byte範圍0~255
	[FieldOffset(0)]
	public byte Airline;

	// 使用無號整數表示起抵機場和航班號  2^16次方
	[FieldOffset(1)]
	public UInt16 Start;

	[FieldOffset(3)]
	public UInt16 End;

	[FieldOffset(5)]
	public UInt16 FlightNo;

	[FieldOffset(7)]
	public byte Cabin;

	// 不使用decimal 價格精確到分儲存
	[FieldOffset(8)]
	public long PriceFen;

	// 使用時間戳替代
	[FieldOffset(16)]
	public long DepTime;

	[FieldOffset(24)]
	public long ArrTime;
}

最後得出的結果,每個數據只需要 32byte 的空間儲存,這樣儲存一億的話也不到 3GB。

本文就不繼續討論這些方式了。

2.2 計算速度

那麼使用結構體有什麼問題嗎?我們來看看計算,這個計算很簡單,就是把符合條件的航線篩選出來,首先類別和結構體都定義了如下程式碼的方法,Explicit 結構體比較特殊,我們使用 Span 比較。

// 類別和結構體定義的方法 當然實際中的篩選可能更加複雜
// 比較航司
public bool EqulasAirline(string airline)
{
    return Airline == airline;
}
// 比較起飛機場
public bool EqualsStart(string start)
{
    return Start == start;
}
// 比較抵達機場
public bool EqualsEnd(string end)
{
    return End == end;
}
// 比較航班號
public bool EqualsFlightNo(string flightNo)
{
    return FlightNo == flightNo;
}
// 價格是否小於指定值
public bool IsPriceLess(decimal min)
{
    return Price < min;
}
// 對於Explicit結構體 定義了EqualsSpan方法
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static unsafe bool SpanEquals(this string str, char* dest, int length)
{
    // 使用span來比較兩個陣列
    return new Span<char>(dest, length).SequenceEqual(str.AsSpan());
}

// 實現的方法如下所示
public static unsafe bool EqualsAirline(FlightPriceStructExplicit item, string airline)
{
    // 傳需要比較的長度
    return airline.SpanEquals(item.Airline, 2);
}
// 下面的方式類似,不再贅述
public static unsafe bool EqualsStart(FlightPriceStructExplicit item, string start)
{
    return start.SpanEquals(item.Start, 3);
}
public static unsafe bool EqualsEnd(FlightPriceStructExplicit item, string end)
{
    return end.SpanEquals(item.End, 3);
}
public static unsafe bool EqualsFlightNo(FlightPriceStructExplicit item, string flightNo)
{
    return flightNo.SpanEquals(item.FlightNo, 4);
}
public static unsafe bool EqualsCabin(FlightPriceStructExplicit item, string cabin)
{
    return cabin.SpanEquals(item.Cabin, 2);
}
public static bool IsPriceLess(FlightPriceStructExplicit item, decimal min)
{
    return item.Price < min;
}

最後 Benchmark 的程式碼如下所示,對於每種儲存結構都是同樣的程式碼邏輯,由於 100W 數據一下就跑完了,每種儲存方式的數據量都為 150W。

// 將需要的數據初始化好  避免對測試造成影響
private static readonly FlightPriceClass[] FlightPrices = FlightPriceCreate.GetClassStore();
private static readonly FlightPriceStruct[] FlightPricesStruct = FlightPriceCreate.GetStructStore();
private static readonly FlightPriceStructUninitialized[] FlightPricesStructUninitialized =
    FlightPriceCreate.GetStructStoreUninitializedArray();
private static readonly FlightPriceStructExplicit[] FlightPricesStructExplicit =
    FlightPriceCreate.GetStructStoreStructExplicit();
// 非受控記憶體比較特殊 只需要儲存指標位址即可
private static IntPtr _unManagerPtr;
private static readonly int FlightPricesStructExplicitUnManageMemoryLength =
    FlightPriceCreate.GetStructStoreUnManageMemory(out _unManagerPtr);
[Benchmark(Baseline = true)]
public int GetClassStore()
{
    var caAirline = 0;
    var shaStart = 0;
    var peaStart = 0;
    var ca0001FlightNo = 0;
    var priceLess500 = 0;
    for (int i = 0; i < FlightPrices.Length; i++)
    {
        // 簡單的篩選數據
        var item = FlightPrices[i];
        if (item.EqualsAirline("CA"))caAirline++;
        if (item.EqualsStart("SHA"))shaStart++;
        if (item.EqualsEnd("PEA"))peaStart++;
        if (item.EqualsFlightNo("0001"))ca0001FlightNo++;
        if (item.IsPriceLess(500))priceLess500++;
    }
    Debug.WriteLine($"{caAirline},{shaStart},{peaStart},{ca0001FlightNo},{priceLess500}");
    return caAirline + shaStart + peaStart + ca0001FlightNo + priceLess500;
}
[Benchmark]
public int GetStructStore()
{
    var caAirline = 0;
    var shaStart = 0;
    var peaStart = 0;
    var ca0001FlightNo = 0;
    var priceLess500 = 0;
    for (int i = 0; i < FlightPricesStruct.Length; i++)
    {
        var item = FlightPricesStruct[i];
        if (item.EqualsAirline("CA"))caAirline++;
        if (item.EqualsStart("SHA"))shaStart++;
        if (item.EqualsEnd("PEA"))peaStart++;
        if (item.EqualsFlightNo("0001"))ca0001FlightNo++;
        if (item.IsPriceLess(500))priceLess500++;
    }
    Debug.WriteLine($"{caAirline},{shaStart},{peaStart},{ca0001FlightNo},{priceLess500}");
    return caAirline + shaStart + peaStart + ca0001FlightNo + priceLess500;
}
[Benchmark]
public int GetFlightPricesStructExplicit()
{
    var caAirline = 0;
    var shaStart = 0;
    var peaStart = 0;
    var ca0001FlightNo = 0;
    var priceLess500 = 0;
    for (int i = 0; i < FlightPricesStructExplicit.Length; i++)
    {
        var item = FlightPricesStructExplicit[i];
        if (FlightPriceStructExplicit.EqualsAirline(item,"CA"))caAirline++;
        if (FlightPriceStructExplicit.EqualsStart(item,"SHA"))shaStart++;
        if (FlightPriceStructExplicit.EqualsEnd(item,"PEA"))peaStart++;
        if (FlightPriceStructExplicit.EqualsFlightNo(item,"0001"))ca0001FlightNo++;
        if (FlightPriceStructExplicit.IsPriceLess(item,500))priceLess500++;
    }
    Debug.WriteLine($"{caAirline},{shaStart},{peaStart},{ca0001FlightNo},{priceLess500}");
    return caAirline + shaStart + peaStart + ca0001FlightNo + priceLess500;
}
[Benchmark]
public unsafe int GetFlightPricesStructExplicitUnManageMemory()
{
    var caAirline = 0;
    var shaStart = 0;
    var peaStart = 0;
    var ca0001FlightNo = 0;
    var priceLess500 = 0;
    var arrays = new Span<FlightPriceStructExplicit>(_unManagerPtr.ToPointer(), FlightPricesStructExplicitUnManageMemoryLength);
    for (int i = 0; i < arrays.Length; i++)
    {
        var item = arrays[i];
        if (FlightPriceStructExplicit.EqualsAirline(item,"CA"))caAirline++;
        if (FlightPriceStructExplicit.EqualsStart(item,"SHA"))shaStart++;
        if (FlightPriceStructExplicit.EqualsEnd(item,"PEA"))peaStart++;
        if (FlightPriceStructExplicit.EqualsFlightNo(item,"0001"))ca0001FlightNo++;
        if (FlightPriceStructExplicit.IsPriceLess(item,500))priceLess500++;
    }
    Debug.WriteLine($"{caAirline},{shaStart},{peaStart},{ca0001FlightNo},{priceLess500}");
    return caAirline + shaStart + peaStart + ca0001FlightNo + priceLess500;
}

Benchmark 的結果如下。

我們看到單獨使用結構體比類別要慢一點點,但是後面那些使用 Explicit 佈局方式和非受控記憶體的就慢很多很多了,有一倍多的差距,魚和熊掌真的不可兼得嗎?

我們來分析一下後面 2 種方式比較慢的原因,原因是因為值拷貝,我們知道在 C# 中預設參考型別是參考傳遞,而實值型別是值傳遞。

  • 參考型別呼叫方法傳遞時只需要拷貝一次,長度為 CPU 字長,32 位系統就是 4byte,64 位就是 8byte
  • 實值型別呼叫方法是值傳遞,比如值需要佔用 4byte,那麼就要拷貝 4byte,在小於等於 CPU 字長時有優勢,大於時優勢就變為劣勢。

而我們的結構體都遠遠大於 CPU 字長 64 位 8byte,而我們的後面的程式碼實現發生了多次值拷貝,這拖慢了整體的速度。

那麼有沒有什麼辦法不發生值拷貝呢?當然,實值型別在 C# 中也可以參考傳遞,我們有 ref 關鍵字,只需要在值拷貝的地方加上就好了,程式碼如下所示。

// 改造比較方法,使其支援參考傳遞
// 加入ref
public static unsafe bool EqualsAirlineRef(ref FlightPriceStructExplicit item, string airline)
{
    // 傳遞的是參考 需要fixed取得指標
    fixed(char* ptr = item.Airline)
    {
        return airline.SpanEquals(ptr, 2);
    }
}

// Benchmark內部程式碼也修改為參考傳遞
[Benchmark]
public unsafe int GetStructStoreUnManageMemoryRef()
{
    var caAirline = 0;
    var shaStart = 0;
    var peaStart = 0;
    var ca0001FlightNo = 0;
    var priceLess500 = 0;
    var arrays = new Span<FlightPriceStructExplicit>(_unManagerPtr.ToPointer(), FlightPricesStructExplicitUnManageMemoryLength);
    for (int i = 0; i < arrays.Length; i++)
    {
        // 從陣列裡面拿直接參考
        ref var item = ref arrays[i];
        // 傳參也直接傳遞參考
        if (FlightPriceStructExplicit.EqualsAirlineRef(ref item,"CA"))caAirline++;
        if (FlightPriceStructExplicit.EqualsStartRef(ref item,"SHA"))shaStart++;
        if (FlightPriceStructExplicit.EqualsEndRef(ref item,"PEA"))peaStart++;
        if (FlightPriceStructExplicit.EqualsFlightNoRef(ref item,"0001"))ca0001FlightNo++;
        if (FlightPriceStructExplicit.IsPriceLessRef(ref item,500))priceLess500++;
    }
    Debug.WriteLine($"{caAirline},{shaStart},{peaStart},{ca0001FlightNo},{priceLess500}");
    return caAirline + shaStart + peaStart + ca0001FlightNo + priceLess500;
}

我們再來跑一下結果,我們的 Explicit 結構體遙遙領先,比使用類別足足快 33%,而上一輪中使用非受控記憶體表現也很好,排在了第二的位置。

那麼同樣是參考傳遞,使用類別會更慢一些呢?這就要回到更加底層的 CPU 相關的知識了,我們 CPU 裡面除了基本的計算單元以外,還有 L1、L2、L3 這些數據快取,如下圖所示。

這個和 CPU 的效能掛鉤,記得文章開頭那一個圖嗎?CPU 內部的快取是速度最快的,所以第一個原因就是對於結構體陣列數據是存放的連續的位址空間,非常利於 CPU 快取;而類別物件,由於是參考型別,需要指標存取,對於 CPU 快取不是很有利。

第二個原因是因為參考型別在存取時,需要進行解參考操作,也就是說需要透過指標找到對應記憶體中的數據,而結構體不需要。

那麼如何驗證我們的觀點呢,其實 BenchmarkDotNet 提供了這樣的指標展示,只需要引入 BenchmarkDotNet.Diagnostics.Windows NuGet 套件,然後在需要評測的類別上面加入以下程式碼。

[HardwareCounters(
    HardwareCounter.LlcMisses, // 快取未命中次數
    HardwareCounter.LlcReference)]  // 解參考次數
public class SpeedBench : IDisposable
{
    ......
}

結果如下所示,由於需要額外的統計 Windows ETW 的資訊,所以跑的會稍微慢一點。

我們可以從上圖看出,使用參考型別快取未命中的次數最多,解參考的次數也很多,這些拖慢了效能。

如下圖所示,順序儲存的結構體要比跳躍式的參考型別記憶體存取效率高。另外物件的體積越小,對於快取就越友好。

文章配圖-類別Cache.drawio

文章配圖-結構體Cache.drawio

3. 總結

在本文章中,我們討論了如何使用結構體替換類別,達到降低大量記憶體佔用和提升幾乎一半計算效能的目的。也討論了非受控記憶體在 .NET 中的簡單使用。結構體是我非常喜歡的東西,它有著相當高效的儲存結構和相當優異的效能。但是你不應該將所有的類別都轉換為結構體,因為它們有不同的適用場景。

那麼我們在什麼時候需要使用結構體,什麼時候需要使用類別呢?微軟官方給出了答案。

✔️ 如果型別的實例比較小並且通常生命週期較短或者常嵌入在其他物件中,則考慮定義結構體而不是類別。

❌ 避免定義結構,除非具有所有以下特徵:

  • 它邏輯上表示單個值,類似於基本型別(int、double 等等)- 比如我們的快取數據,基本都是基本型別。
  • 它的實例大小小於 16 位元組 - 值拷貝的代價是巨大的,不過現在有了 ref 能有更多的適用場景。
  • 它是不可變的 - 在我們今天的例子中,快取的數據是不會改變的,所以具有這個特徵。
  • 它不必頻繁裝箱 - 頻繁裝拆箱對效能有較大的損耗,在我們的場景中,函式都做了 ref 適配,所以也不存在這種情況。

在所有其他情況下,都應將型別定義為類別。

其實大家從這些方式也能看出來,C# 是一款入門簡單但是上限很高的語言,平時可以利用 C# 的語法特性,快速的進行需求變現;而如果有了效能瓶頸,你完全可以像寫 C++ 程式碼一樣寫 C# 程式碼,獲得和 C++ 媲美的效能。

4. 附錄

繼續探索

延伸閱讀

更多文章
同分類 / 同標籤 2024/3/14

C#與Java

在動態且不斷演進的軟體開發世界中,Java 和 C# 是兩個巨頭,每個都有自己獨特的優勢、理念和生態系統。本文深入比較了 Java 和 C#,探討了它們的歷史背景、語言特性、效能指標、跨平台功能等。

繼續閱讀