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

2023-11-06 09:00:21

在《NativeBuffering,一種高效能、零記憶體分配的序列化解決方案[效能測試篇]》我比較了NativeBuffering和System.Text.Json兩種序列化方式的效能,通過效能測試結果可以看出NativeBuffering具有非常明顯的優勢,有的方面的效能優勢甚至是「碾壓式」的,唯獨針對字串的序列化效能不夠理想。我趁這個週末對此做了優化,解決了這塊短板,接下來我們就來看看最新的效能測試結果和背後「加速」的原理。

一、新版的效能測試結果

我使用《NativeBuffering,一種高效能、零記憶體分配的序列化解決方案[效能測試篇]》提供的測試用例,選用的依然是如下這個Person型別,它的絕大部分資料成員都是字串。

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

這是採用的測試案例。Benchmark方法SerializeAsJson直接將靜態欄位Instance表示的Person物件序列化成JSON字串,採用NativeBuffering的Benchmark方法SerializeAsNativeBuffering直接呼叫WriteTo擴充套件方法(通過Source Generator生成)對齊進行序列化,並利用一個ArraySegment<T>結構返回序列化結果。WriteTo方法具有一個型別為Func<int, byte[]>的引數,我們使用它來提供一個存放序列化結果的位元組陣列。作為Func<int, byte[]>輸入引數的整數代表序列化結果的位元組長度,這樣我們才能確保提供的位元組陣列具有充足的儲存空間。

[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具有「零記憶體分配」的巨大優勢,但是在耗時上會多一些。造成這個劣勢的主要原因來源於針對字串的編碼,因為NativeBuffering在序列化過程需要涉及兩次編碼,一次是為了計算總的位元組數,另一次才是生成序列化結果。

image

如果切換到目前最新版本(0.1.5),可以看出NativeBuffering的效能已經得到了極大的改善,並且明顯優於JSON序列化的效能(對於JSON序列化,兩次測試具體的耗時之所以具有加大的差異,是因為測試機器設定不同,12代和13代i7的差異)。而在記憶體分配層面,針對NativeBuffering的序列化依然是「零分配」。

image

二、背後的故事

接下來我們就來簡單說明一下為什麼NativeBuffering針對字串的序列化明顯優於JSON序列化,這要從BufferedString這個自定義的結構說起。如下所示的就是Source Generator為Person型別生成的BufferedMessage型別,可以看出它的原有的字串型別的成員在此型別中全部轉換成了BufferedString型別的唯讀屬性。

public unsafe readonly struct PersonBufferedMessage : IReadOnlyBufferedObject<PersonBufferedMessage>
{
    public static PersonBufferedMessage DefaultValue => throw new NotImplementedException();
    public NativeBuffer Buffer { get; }
    public PersonBufferedMessage(NativeBuffer buffer) => Buffer = buffer;
    public static PersonBufferedMessage Parse(NativeBuffer buffer) => new PersonBufferedMessage(buffer);
    public BufferedString Name => Buffer.ReadNonNullableBufferedObjectField<BufferedString>(0);
    public System.Int32 Age => Buffer.ReadUnmanagedField<System.Int32>(1);
    public ReadOnlyNonNullableBufferedObjectList<BufferedString> Hobbies => Buffer.ReadNonNullableBufferedObjectCollectionField<BufferedString>(2);
    public BufferedString Address => Buffer.ReadNonNullableBufferedObjectField<BufferedString>(3);
    public BufferedString PhoneNumber => Buffer.ReadNonNullableBufferedObjectField<BufferedString>(4);
    public BufferedString Email => Buffer.ReadNonNullableBufferedObjectField<BufferedString>(5);
    public BufferedString Gender => Buffer.ReadNonNullableBufferedObjectField<BufferedString>(6);
    public BufferedString Nationality => Buffer.ReadNonNullableBufferedObjectField<BufferedString>(7);
    public BufferedString Occupation => Buffer.ReadNonNullableBufferedObjectField<BufferedString>(8);
    public BufferedString EducationLevel => Buffer.ReadNonNullableBufferedObjectField<BufferedString>(9);
    public BufferedString MaritalStatus => Buffer.ReadNonNullableBufferedObjectField<BufferedString>(10);
    public BufferedString SpouseName => Buffer.ReadNonNullableBufferedObjectField<BufferedString>(11);
    public System.Int32 NumberOfChildren => Buffer.ReadUnmanagedField<System.Int32>(12);
    public ReadOnlyNonNullableBufferedObjectList<BufferedString> ChildrenNames => Buffer.ReadNonNullableBufferedObjectCollectionField<BufferedString>(13);
    public ReadOnlyNonNullableBufferedObjectList<BufferedString> LanguagesSpoken => Buffer.ReadNonNullableBufferedObjectCollectionField<BufferedString>(14);
    public System.Boolean HasPets => Buffer.ReadUnmanagedField<System.Boolean>(15);
    public ReadOnlyNonNullableBufferedObjectList<BufferedString> PetNames => Buffer.ReadNonNullableBufferedObjectCollectionField<BufferedString>(16);
}

BufferedString在NativeBuffering中用來表示字串。如程式碼片段所示,BufferedString 同樣實現了IReadOnlyBufferedObject<BufferedString>介面,以為著它也是對一段位元組序列的封裝。BufferedString提供了針對字串型別的隱式轉換,所以我們在程式設計的時候可以將它當成普通字串來使用。

public unsafe readonly struct BufferedString : IReadOnlyBufferedObject<BufferedString>
{
    public static BufferedString DefaultValue { get; }
    static BufferedString()
    {
        var size = CalculateStringSize(string.Empty);
        var bytes = new byte[size];

        var context = BufferedObjectWriteContext.Create(bytes);
        context.WriteString(string.Empty);
        DefaultValue = new BufferedString(new NativeBuffer(bytes));
    }
    public BufferedString(NativeBuffer buffer) => _start = buffer.Start;
    public BufferedString(void* start) => _start = start;

    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public static BufferedString Parse(NativeBuffer buffer) =>  new(buffer);

    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public static BufferedString Parse(void* start) => new(start);

    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public static int CalculateSize(void* start) => Unsafe.Read<int>(start);

    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public string AsString()
    {
        string v = default!;
        Unsafe.Write(Unsafe.AsPointer(ref v), new IntPtr(Unsafe.Add<byte>(_start, IntPtr.Size * 2)));
        return v;
    }

    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public static implicit operator string(BufferedString value) => value.AsString();

    public override string ToString() => AsString();

    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public static int CalculateStringSize(string? value)
    {
        var byteCount = value is null ? 0 : Encoding.Unicode.GetByteCount(value);
        var size = _headerByteCount + byteCount;
        return Math.Max(IntPtr.Size * 3 + sizeof(int), size);
    }

    private static readonly int _headerByteCount = sizeof(nint) + sizeof(nint) + sizeof(nint) + sizeof(int);
}

值得一提的是,BufferedString向String的型別轉換是沒有任何開銷的,這一切源自它封裝的這段位元組序列的結構。我曾經在《你知道.NET的字串在記憶體中是如何儲存的嗎?》中介紹過字串物件自身在記憶體中的佈局,而BufferedString封裝的位元組序列就是在這段內容加上前置的4/8個位元組(x84為4位元組,x64需要新增4位元組Padding確保記憶體對齊)來表示總的位元組數。當BufferedString轉換成String型別時,只需要將返回的字串變數指向TypeHandle部分的地址就可以了,這一點體現在上述的AsString方法上。

image

也正是因為NativeBuffering在序列化字串的時候,生成的位元組序列與字串物件的記憶體佈局一致,所以不在需要對字串進行編碼,直接按照如下所示的方式進行記憶體拷貝就可以了。這正是NativeBuffering針對字串的序列化的效能得以提升的原因,不過整個序列化過程中還是需要計算字串針對預設編碼(Unicode)的位元組長度。

image