第一版的NativeBuffering([上篇]、[下篇])釋出之後,我又對它作了多輪迭代,對效能作了較大的最佳化。比如確保所有型別的資料都是記憶體對齊的,內部採用了池化機器確保真正的“零記憶體分配”等。對於字典型別的資料成員,原來只是“表現得像個欄位”,這次真正使用一段連續的記憶體構架了一個“雜湊表”。我們知道對於每次.NET新版本的釋出,原生的JSON序列化(System.Text.Json)的效能都作了相應的提升,本篇文章透過具體的效能測試比較NativeBuffering和它之間的效能差異。
一、一種“特別”的序列化解決方案
二、Source Generator驅動的程式設計模式
三、序列化效能比較
四、原生型別效能“友好”
五、Unmanaged 型別“效能加速”
六、無需反序列化
七、資料讀取的成本
一、一種“特別”的序列化解決方案
一般的序列化/發序列化都是資料物件和序列化結果(字串或者單純的位元組序列)之間的轉換。以下圖為例,我們定義了一個名為Person的資料型別,如果採用典型的JSON序列化方案,序列化器會將該物件轉換成一段具有JSON格式的字串,這段字串可以透過反序列化的方式“恢復”成一個Person物件。
如果採用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 = "bill@gmail.com", 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檔案中。
我們使用下的程式碼來演示針對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物件使完全一致的。
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多的記憶體。
四、原生型別效能“友好”
從上面展示的效能測試結果可以看出,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序列化的一半,並且它依然沒有任何記憶體分配。
五、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。
六、無需反序列化
對於序列化來說,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反序列化的成本還是巨大的,不僅反映在耗時上,同時也反映在記憶體分配上。
七、資料讀取的成本
上面的測試結果也體現了NativeBuffering針對資料讀取的成本。和普通型別直接讀取欄位的值不同,NativeBuffering生成的BufferedMessage物件是對一段連續位元組序列的封裝,此位元組序列就是序列化的結果。如下所示的是這段位元組序列的佈局:整個序列包括兩個部分,後面一部分依次儲存每個欄位的內容,前面一部分則儲存每個欄位內容在整個位元組序列的位置(偏移量)。
BufferedMessage的每個資料成員都是隻讀屬性,針對資料成員的讀取至少需要兩個步驟:
- 根據資料成員的序號讀取儲存內容的偏移量;
- 將偏移量轉換成記憶體地址,結合當前資料型別將資料讀出來;
所以NativeBuffering最大的問題就是:讀取資料成員的效能肯定比直接讀取欄位值要高。從上面的測試結果大體可以測出單次讀取耗時大體在1-2奈米之間(24.87ns包括建立EntityBufferedMessage和呼叫空方法Process的耗時),也就是說1秒中可以完成5-10億次讀取。我想這個讀取成本大部分應用是可以接受的,尤其是相對於它在序列化/反序列化在耗時和記憶體分配帶來的巨大優勢來說,讀取資料成員帶來時間損耗基本上可以忽略了。