NativeBuffering,一種高效能、零記憶體分配的序列化解決方案[效能測試篇]

2023-11-02 12:01:35

第一版的NativeBuffering[上篇][下篇])釋出之後,我又對它作了多輪迭代,對效能作了較大的優化。比如確保所有型別的資料都是記憶體對齊的,內部採用了池化機器確保真正的「零記憶體分配」等。對於字典型別的資料成員,原來只是「表現得像個欄位」,這次真正使用一段連續的記憶體構架了一個「雜湊表」。我們知道對於每次.NET新版本的釋出,原生的JSON序列化(System.Text.Json)的效能都作了相應的提升,本篇文章通過具體的效能測試比較NativeBuffering和它之間的效能差異。

一、一種「特別」的序列化解決方案
二、Source Generator驅動的程式設計模式
三、序列化效能比較
四、原生型別效能「友好」
五、Unmanaged 型別「效能加速」
六、無需反序列化
七、資料讀取的成本

一、一種「特別」的序列化解決方案

一般的序列化/發序列化都是資料物件和序列化結果(字串或者單純的位元組序列)之間的轉換。以下圖為例,我們定義了一個名為Person的資料型別,如果採用典型的JSON序列化方案,序列化器會將該物件轉換成一段具有JSON格式的字串,這段字串可以通過反序列化的方式「恢復」成一個Person物件。

image

如果採用NativeBuffering序列化解決方案,它會引入一個新的資料型別PersonBufferedMessage,我們採用Source Generator的方式根據Person的資料結構自動生成PersonBufferedMessage型別。除此之外,PersonBufferedMessage還會為Person生成額外的方式將自身物件以位元組的方式寫入提供的緩衝區。

換句話說,Person物件會轉換成一段連續的位元組序列化,PersonBufferedMessage就是對這段位元組序列的封裝。它的資料成員(Name、Age和City)不再定義成「地段」,而被定義成「唯讀屬性」,它能找到對應的資料成員在這段位元組序列中的位置,從而將其讀出來。為了提供資料讀取的效能,所有的資料成員在序列化位元組序列中總是按照「原生(Native)」的形式儲存,並且確保是記憶體對齊的。也正是這個原因,NativeBuffering並不是一個跨平臺的序列化解決方案。

二、Source Generator驅動的程式設計模式

NativeBuffering的整個程式設計圍繞著「Source Generator」進行的,接下來我們簡單演示一下如何使用它。我們在一個控制檯程式中新增NativeBuffering相關的兩個NuGet包NativeBuffering和NativeBuffering.Generator(使用最新版本),並定義如下這個資料型別Person。由於我們需要為Person生成額外的型別成員,我們必須將其定義成partial class。

[BufferedMessageSource]
public partial class Person
{
    public string Name { get; set; }
    public int Age { get; set; }
    public string[] Hobbies { get; set; }
    public string Address { get; set; }
    public string PhoneNumber { get; set; }
    public string Email { get; set; }
    public string Gender { get; set; }
    public string Nationality { get; set; }
    public string Occupation { get; set; }
    public string EducationLevel { get; set; }
    public string MaritalStatus { get; set; }
    public string SpouseName { get; set; }
    public int NumberOfChildren { get; set; }
    public string[] ChildrenNames { get; set; }
    public string[] LanguagesSpoken { get; set; }
    public bool HasPets { get; set; }
    public string[] PetNames { get; set; }

    public static Person Instance = new Person
    {
        Name = "Bill",
        Age = 30,
        Hobbies = new string[] { "Reading", "Writing", "Coding" },
        Address = "123 Main St.",
        PhoneNumber = "555-555-5555",
        Email = "[email protected]",
        Gender = "M",
        Nationality = "China",
        Occupation = "Software Engineer",
        EducationLevel = "Bachelor's",
        MaritalStatus = "Married",
        SpouseName = "Jane",
        NumberOfChildren = 2,
        ChildrenNames = new string[] { "John", "Jill" },
        LanguagesSpoken = new string[] { "English", "Chinese" },
        HasPets = true,
        PetNames = new string[] { "Fido", "Spot" }
    };
}

我們在型別上標註BufferedMessageSourceAttribute特性將其作為BufferedMessage的「源」。此時如果我們檢視VS的Solution Explorer,就會從專案的Depedences/Analyers/NativeBuffering.Generator看到生成的兩個.cs檔案。我們使用的PersonBufferedMessage就定義在PersonBufferedMessage.g.cs檔案中。為Person額外新增的型別成員就定義在Person.g.cs檔案中。

image

我們使用下的程式碼來演示針對Person和PersonBufferedMessage的序列化和反序列化。如下面的程式碼片段所示,我們利用Instance靜態屬性得到Person單例物件,直接呼叫其WriteToAsync方法(Person.g.cs檔案會使Person型別實現IBufferedObjectSource介面,WriteToAsync方法使針對該介面定義的擴充套件方法)對自身進行序列化,並將作為序列化結果的位元組序列儲存到指定的檔案(person.bin)檔案中。

using NativeBuffering;
var fileName = "person.bin";
await Person.Instance.WriteToAsync(fileName);
using (var pooledBufferedMessage = await BufferedMessage.LoadAsync<PersonBufferedMessage>(fileName))
{
    var bufferedMessage = pooledBufferedMessage.BufferedMessage;
    Console.WriteLine(
@$"{nameof(bufferedMessage.Name),-17}: {bufferedMessage.Name}
{nameof(bufferedMessage.Age),-17}: {bufferedMessage.Age}
{nameof(bufferedMessage.Hobbies),-17}: {string.Join(", ", bufferedMessage.Hobbies)}
{nameof(bufferedMessage.Address),-17}: {bufferedMessage.Address}
{nameof(bufferedMessage.PhoneNumber),-17}: {bufferedMessage.PhoneNumber}
{nameof(bufferedMessage.Email),-17}:{bufferedMessage.Email}
{nameof(bufferedMessage.Nationality),-17}:{bufferedMessage.Nationality},
{nameof(bufferedMessage.Occupation),-17}:{bufferedMessage.Occupation},
{nameof(bufferedMessage.EducationLevel),-17}:{bufferedMessage.EducationLevel}
{nameof(bufferedMessage.MaritalStatus),-17}:{bufferedMessage.MaritalStatus}
{nameof(bufferedMessage.SpouseName),-17}:{bufferedMessage.SpouseName}
{nameof(bufferedMessage.NumberOfChildren),-17}:{bufferedMessage.NumberOfChildren}
{nameof(bufferedMessage.ChildrenNames),-17}: {string.Join(", ", bufferedMessage.ChildrenNames)}
{nameof(bufferedMessage.LanguagesSpoken),-17}: {string.Join(", ", bufferedMessage.LanguagesSpoken)}
{nameof(bufferedMessage.HasPets),-17}:{bufferedMessage.HasPets}
{nameof(bufferedMessage.PetNames),-17}: {string.Join(", ", bufferedMessage.PetNames)}");
}

然後我們呼叫BufferedMessage的靜態方法LoadAsync<PersonBufferedMessage>載入該檔案的內容。該方法會返回一個PooledBufferedMessage<PersonBufferedMessage>物件,它的BufferedMessage返回我們需要的PersonBufferedMessage物件。PersonBufferedMessage具有與Person一致的資料成員,我們將它們的內容一一輸出,可以看出PersonBufferedMessage承載的內容與Person物件使完全一致的。

image

NativeBuffering之所以能供實現真正意義的「零記憶體分配」,得益於對「池化機制」的應用。LoadAsync<T>方法返回的PooledBufferedMessage<T>使用一段池化的快取區來儲存序列化的位元組,當我們不再使用的時候,需要呼叫其Dispose方法快取區釋放到快取池內。

三、序列化效能比較

接下來我們以就以上面定義的Person型別為例,利用BenchmarkDotNet比較一下NativeBuffering與JSON序列化在效能上的差異。如下面的程式碼片段所示,針對JSON序列化的Benchmark方法直接呼叫JsonSerializer的Serialize方法將Person單例物件序列化成字串。

[MemoryDiagnoser]
public class Benchmark
{
    private  static readonly Func<int, byte[]> _bufferFactory = ArrayPool<byte>.Shared.Rent;

    [Benchmark]
    public string SerializeAsJson() => JsonSerializer.Serialize(Person.Instance);

    [Benchmark]
    public void SerializeNativeBuffering()
    {
        var arraySegment = Person.Instance.WriteTo(_bufferFactory);
        ArrayPool<byte>.Shared.Return(arraySegment.Array!);
    }
}

在針對NativeBuffering的Benchmark方法中,我們呼叫Person單例物件的WriteTo擴充套件方法對齊進行序列化,並利用一個ArraySegment<T>結構返回序列化結果。WriteTo方法具有一個型別為Func<int, byte[]>的引數,我們使用它來提供一個存放序列化結果的位元組陣列。作為Func<int, byte[]>輸入引數的整數代表序列化結果的位元組長度,這樣我們才能確保提供的位元組陣列具有充足的儲存空間。

為了避免記憶體分配,我們利用這個委託從ArrayPool<byte>.Shared表示的「陣列池」中借出一個大小適合的位元組陣列,並在完成序列化之後將其釋放。這段效能測試結果如下,可以看出從耗時來看,針對NativeBuffering的序列化稍微多了一點,但是從記憶體分配來看,它真正做到了記憶體的「零分配」,而JSON序列化則分配了1K多的記憶體。

image

四、原生型別效能「友好」

從上面展示的效能測試結果可以看出,NativeBuffering在序列化上確實可以不用分配額外的記憶體,但是耗時似乎多了點。那麼是否意味著NativeBuffering不如JSON序列化高效嗎?其實也不能這麼說。NativeBuffering會使用一段連續的記憶體(而不是多段快取的拼接)來儲存序列化結果,所以它在序列化之前需要先計算位元組數。由於Person定義的絕大部分資料成員都是字串,這導致了它需要計算字串編碼後的位元組數,這個計算會造成一定的耗時。

所以字串不是NativeBuffering的強項,對於其他資料型別,NativeBuffering效能其實很高的。現在我們重新定義如下這個名為Entity的資料型別,它將常用的Primitive型別和一個位元組陣列作為資料成員

[BufferedMessageSource]
public partial class Entity
{
    public byte ByteValue { get; set; }
    public sbyte SByteValue { get; set; }
    public short ShortValue { get; set; }
    public ushort UShortValue { get; set; }
    public int IntValue { get; set; }
    public uint UIntValue { get; set; }
    public long LongValue { get; set; }
    public ulong ULongValue { get; set; }
    public float FloatValue { get; set; }
    public double DoubleValue { get; set; }
    public decimal DecimalValue { get; set; }
    public bool BoolValue { get; set; }
    public char CharValue { get; set; }
    public byte[] Bytes { get; set; }

    public static Entity Instance = new Entity
    {
        ByteValue = 1,
        SByteValue = 2,
        ShortValue = 3,
        UShortValue = 4,
        IntValue = 5,
        UIntValue = 6,
        LongValue = 7,
        ULongValue = 8,
        FloatValue = 9,
        DoubleValue = 10,
        DecimalValue = 11,
        BoolValue = true,
        CharValue = 'a',
        Bytes = Enumerable.Range(0, 128).Select(it => (byte)it).ToArray()
    };
}

然後我們將效能測試的兩個Benchmark方法使用的資料型別從Person改為Entity。

[MemoryDiagnoser]
public class Benchmark
{
    private static readonly Func<int, byte[]> _bufferFactory = ArrayPool<byte>.Shared.Rent;

    [Benchmark]
    public string SerializeAsJson() => JsonSerializer.Serialize(Entity.Instance);

    [Benchmark]
    public void SerializeNativeBuffering()
    {
        var arraySegment = Entity.Instance.WriteTo(_bufferFactory);
        ArrayPool<byte>.Shared.Return(arraySegment.Array!);
    }
}

再來看看如下的測試結果,可以看出NativeBuffering序列化的耗時差不多是JSON序列化的一半,並且它依然沒有任何記憶體分配

image

五、Unmanaged 型別「效能加速」

NativeBuffering不僅僅對Primitive型別「友好」,對於自定義的Unmanaged結構,更能體現其效能優勢。原因很簡單,Unmanaged型別(含Primitive型別和自定義的unmanaged結構)的記憶體佈局就是連續的,NativeBuffering在進行序列化的適合不需要對它進行「分解」,直接拷貝這段記憶體的內容就可以了。

作為演示,我們定義瞭如下這個Foobarbazqux結構體,可以看出它滿足unmanaged結構的要求。作為序列化資料型別的Record中,我們定義了一個Foobarbazqux陣列型別的屬性Data。Instance靜態欄位表示的單例物件的Data屬性包含100個Foobarbazqux物件。

[BufferedMessageSource]
public partial class Record
{
    public Foobarbazqux[] Data { get; set; } = default!;
    public static Record Instance = new Record {  Data = Enumerable.Range(1, 100).Select(_ => new Foobarbazqux(new Foobarbaz(new Foobar(111, 222), 1.234f), 3.14d)).ToArray()};
}

public readonly record struct Foobar(int Foo, long Bar);
public readonly record struct Foobarbaz(Foobar Foobar, float Baz);
public readonly record struct Foobarbazqux(Foobarbaz Foobarbaz, double Qux);

我們同樣只需要將效能測試的資料型別改成上面定義的Record就可以了。

[MemoryDiagnoser]
public class Benchmark
{
    private static readonly Func<int, byte[]> _bufferFactory = ArrayPool<byte>.Shared.Rent;

    [Benchmark]
    public string SerializeAsJson() => JsonSerializer.Serialize(Record.Instance);

    [Benchmark]
    public void SerializeNativeBuffering()
    {
        var arraySegment = Record.Instance.WriteTo(_bufferFactory);
        ArrayPool<byte>.Shared.Return(arraySegment.Array!);
    }
}

這次NativeBuffering針對JSON序列化的效能優勢完全是「碾壓式」的。耗時:72us/3us。JSON序列化不僅帶來了26K的記憶體分配,還將部分記憶體提升到了Gen1。

image

六、無需反序列化

對於序列化來說,NativeBuffering不僅僅可以避免記憶體的分配。如果不是大規模涉及字串,它在耗時方面依然具有很大的優勢。即使大規模使用字串,考慮到JSON字串最終還是需要編碼轉換成位元組序列化,兩者之間的總體耗時其實差別也不大。NativeBuffering針對反序列化的效能優勢更是毋庸置疑,因為我們使用的BufferedMessage就是對序列化結果的封裝,所以反序列化的成本幾乎可以忽略(經過測試耗時在幾納秒)。

為了讓大家能夠感覺到與JSON分序列化的差異,我們將讀取資料成員的操作也作為反序列化的一部分。如下面這個Benchmark所示,我們在初始化自動執行的Setup方法中,針對同一個Entity物件的兩種序列化結果(位元組陣列)儲存在_encodedJson 和_payload欄位中。

[MemoryDiagnoser]

public class Benchmark
{
    private  byte[] _encodedJson = default!;
    private  byte[] _payload = default!;

    [GlobalSetup]
    public void Setup()
    {
        _encodedJson = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(Entity.Instance));
        _payload = new byte[Entity.Instance.CalculateSize()];
        Person.Instance.WriteToAsync(new MemoryStream(_payload), true);
    }

    [Benchmark]
    public void DeserializeFromJson()
    {
        var entity = JsonSerializer.Deserialize<Entity>(Encoding.UTF8.GetString(_encodedJson))!;
        Process(entity.ByteValue);
        Process(entity.SByteValue);
        Process(entity.ShortValue);
        Process(entity.UShortValue);
        Process(entity.IntValue);
        Process(entity.UIntValue);
        Process(entity.LongValue);
        Process(entity.ULongValue);
        Process(entity.FloatValue);
        Process(entity.DoubleValue);
        Process(entity.DecimalValue);
        Process(entity.BoolValue);
        Process(entity.CharValue);
        Process(entity.Bytes);
    }

    [Benchmark]
    public void DeserializeFromNativeBuffering()
    {
        unsafe
        {
            fixed (byte* _ = _payload)
            {
                var entity = new EntityBufferedMessage(new NativeBuffer(_payload));
                Process(entity.ByteValue);
                Process(entity.SByteValue);
                Process(entity.ShortValue);
                Process(entity.UShortValue);
                Process(entity.IntValue);
                Process(entity.UIntValue);
                Process(entity.LongValue);
                Process(entity.ULongValue);
                Process(entity.FloatValue);
                Process(entity.DoubleValue);
                Process(entity.DecimalValue);
                Process(entity.BoolValue);
                Process(entity.CharValue);
                Process(entity.Bytes);
            }
        }
    }

    [MethodImpl(MethodImplOptions.NoInlining)]
    private void Process<T>(T expected)
    { }
}

針對JSON反序列化的Benchmark方法利用JsonSerializer將解碼生成的字串反序列化成Entity物件,並呼叫Process方法讀取每個資料成員。在針對NativeBuffering的Benchmark方法中,我們需要建立一個fixed上下文將位元組陣列記憶體地址固定,因為BufferedMessage的讀取涉及很多Unsafe的記憶體地址操作,然後將這個位元組陣列封裝成NativeBuffer物件,並據此將EntityBufferedMessage建立出來。這個方法的耗時花在後面針對資料成員的讀取上。如下所示的兩種「反序列」方式的測試結果。從如下所示的測試結果可以看出相對於NativeBuffering的無需反序列化,JSON反序列化的成本還是巨大的,不僅反映在耗時上,同時也反映在記憶體分配上。

image

七、資料讀取的成本

上面的測試結果也體現了NativeBuffering針對資料讀取的成本。和普通型別直接讀取欄位的值不同,NativeBuffering生成的BufferedMessage物件是對一段連續位元組序列的封裝,此位元組序列就是序列化的結果。如下所示的是這段位元組序列的佈局:整個序列包括兩個部分,後面一部分依次儲存每個欄位的內容,前面一部分則儲存每個欄位內容在整個位元組序列的位置(偏移量)。

image

BufferedMessage的每個資料成員都是唯讀屬性,針對資料成員的讀取至少需要兩個步驟:

  • 根據資料成員的序號讀取儲存內容的偏移量;
  • 將偏移量轉換成記憶體地址,結合當前資料型別將資料讀出來;

所以NativeBuffering最大的問題就是:讀取資料成員的效能肯定比直接讀取欄位值要高。從上面的測試結果大體可以測出單次讀取耗時大體在1-2納米之間(24.87ns包括建立EntityBufferedMessage和呼叫空方法Process的耗時),也就是說1秒中可以完成5-10億次讀取。我想這個讀取成本大部分應用是可以接受的,尤其是相對於它在序列化/反序列化在耗時和記憶體分配帶來的巨大優勢來說,讀取資料成員帶來時間損耗基本上可以忽略了。