javascript
在 .NET Core 3.0 中实现 JIT 编译的 JSON 序列化,及一些心得与随想
源碼:https://github.com/Martin1994/JsonJitSerializer
NuGet:https://www.nuget.org/packages/MartinCl2.Text.Json.Serialization/
簡介:Just-in-time 編譯的 JSON 序列化,基于 System.Text.Json
.NET Core 3.0 即將正式發布,其中一項令人振奮的功能是 corefx 集成了一個 JSON 庫用來替代?JSON.NET,目前我按照 namespace 稱這套庫為 System.Text.Json。
這一套 JSON 庫吸取了一部分?JSON.NET?的教訓,將 API 的功能盡可能分離。例如它除了提供了 Object 與 String/Stream 之間的序列化與反序列化的高層 API 之外,還提供了逐 token 讀寫的底層 API。這為第三方開發者實現自己的 JSON 庫提供了極大的方便。
了解到這一點后我意識到可以用這套底層 API(具體來說是?Utf8JsonWriter)來實現一個 just-in-time 編譯(本質上其實是 IL generation)的 JSON 序列化庫。
為何 JSON 序列化可以從 JIT 中受益呢?
System.Text.Json?實現 JSON 序列化的步驟是:
利用反射讀出需要序列化的 class 的結構;
緩存每個需要序列化的 property,包括其名字(用 UTF-8 存儲)、getter method 以及對應的 converter;
每次需要序列化的時候逐條讀取這個結構化的緩存并利用?Utf8JsonWriter?序列化為 JSON stream。
可以注意到步驟 2 到 3 其實有點類似于解釋執行的腳本語言。既然是解釋執行,那自然可以有其對應的 JIT 優化,將解釋的內容直接編譯成可執行的代碼。這樣可以省去一些存取的開銷和動態類型檢查的開銷。具體可以減小多少開銷可以參照 benchmark 的結果:
System.Text.Json_Async | Mean - 592.6 ns | Allocated Memory/Op - 304 B
MartinCl2.Text.Json_Async | Mean - 346.0 ns | Allocated Memory/Op - 152 B
其中第二行是這個庫的數值。取自?Json_ToStream_LoginViewModel_?測試。完整數據請見附錄。
此外,這個序列化庫的所有 public API 的簽名及行為我盡可能的保持與?System.Text.Json?保持一致,因此采用了?System.Text.Json?的代碼應該可以幾乎無縫地與這個 JIT 序列化庫來回切換。
System.Text.Json:
static async Task CompileAndSerializeAsync<T>(Stream stream, T obj) {JsonSerializerOptions options = new JsonSerializerOptions(){PropertyNamingPolicy = JsonNamingPolicy.CamelCase};await JsonSerializer.SerializeAsync(stream, obj, options); }JsonJitSerializer:
static async Task CompileAndSerializeAsync<T>(Stream stream, T obj) {JsonJitSerializer<T> serializer = JsonJitSerializer<T>.Compile(new JsonSerializerOptions(){PropertyNamingPolicy = JsonNamingPolicy.CamelCase});await serializer.SerializeAsync(stream, obj); }JIT 與 code generator 相比的優劣
類似的優化完全可以通過 code generator 實現,區別在于 code generator 在編譯前生成 C# code,而 JIT 在運行時生成 IL code。
最顯著的區別顯然是 JIT 是在運行時才知道需要序列化的 class 的結構,而 code generator 必須事先知道。但其實需要運行時才知道結構的情況非常少見,所以這一點上區別不大。
但是從功能上來看,運行時才知道結構的 JIT 顯然就更自由了。比方說?System.Text.Json?提供了自定義的 converter,而具體的 converter 是要到運行時才知道的,code generator 對此只能將 converter 當作抽象的接口來處理,而 JIT 卻可以直接精細到具體的 class 甚至是 instance。
從啟動時間上來看 code generator 或許會占有一定優勢,因為 JIT 的運行時編譯,包括其中 reflection 的消耗,多少會占用一定的資源。這一點在需要經常冷啟動的 serverless 架構上或許會更明顯。
從摳墻縫級別的優化來看兩者各有千秋。JIT 由于是直接生成 IL,跳過了 C# 的抽象,可以做一些很極端的優化,例如跳過類型檢查、激進的 devirtualization 等。但反過來說,code generator 由于最終還是由 C# 編譯器編譯,因此可以享受到很多編譯器帶來的優化,例如函數內聯。我在實現這個 JIT serializer 的時候必須重新手動實現很多本來是編譯器負責的優化,例如?foreach?中,數組(例如?T[])、value type enumerator(例如?List<T>) 和 reference type enumerator(例如?IEnumerable<T>)出于性能目的是會分別編譯成不同的 IL 的,而不是簡單的?IEnumerator?那幾個函數的語法糖(詳見下文「GC-free C# 編程」的最后)。
從實現難度上來看,也各有各的難處。從生成代碼的可讀性和調試難度上來看,code generator 更好,畢竟最終就是 C# 代碼。從讀取 class 結構的難度上來看 JIT 簡直是白送的,因為可以直接使用 C# 的 reflection。不過考慮到 C# 的編譯器 Roslyn 的口碑,說不定從源碼讀 class 結構這件事沒有預想中那么難。
而對我來說很重要的一點是與開發環境的集成性。在用戶使用 JIT 方案的時候對開發環境的侵入性是 0,換句話說不需要任何其他的工具鏈就能直接無縫使用。而 code generator 方案則有大大小小的侵入,這一點在高度依賴 code generator 的 Java 開發中有非常明顯的體現,因為其本質類似于 MACRO。例如 IDE 幾乎必須要依賴專門編寫的插件才能在編譯前就獲得 generated code 中的 symbol。Java 的 lombok 是一個具體的例子:若是沒有 lombok 插件, IntelliJ Idea 完全無法知道有自動生成的 getter 和 setter 的存在。另一個侵入的例子是:由于 code generator 在編譯過程中需要提前生成代碼,那或多或少就需要自定義編譯鏈。舉例來說有很多上古時代的 C 項目中有 generated C code,而我做 Windows 移植的時候必須先想辦法讓 20 年前的 code generator 跑起來之后才能編譯,而調用這個 code generator 的 bash script 中又會出現各式目瞪口呆的不兼容。此外在自定義編譯鏈的情況下,CI/CD 也需要額外的配置。如果編譯鏈中有多個 code generator,甚至還有先后順序的依賴,項目一大就會產生巨大的痛苦。
編寫這套庫時的一些心得及隨想
Generated IL 的調試
有一些 Visual Studio 的插件可以看到生成的 IL,但我沒有找到 VSCode 上的實現,因此我是完全靠肉眼定位 bug 的。
首先,使用?Emit?生成的 IL 會有少量的 validation。例如 .NET 會幫你確認棧是不是平衡的(MSIL 是基于棧的虛擬機)。如果碰到有 runtime exception 說什么 invalid IL,那多半是少壓了一個棧。
但是多數情況下靜態分析不能找出太多錯誤。例如棧壓反了的時候只會有 runtime exception,有時甚至連 runtime exception 都不會有。這是因為 MSIL 其實是沒有自動類型檢查的,只有少數幾個指令自帶了類型檢查。比方說你可以壓一個?DateTime?在在棧上,然后調用?String.Length,多半是會給出一個亂七八糟的數而不是報錯。調試這種問題就需要一些奇技淫巧了。
在 .NET Framework 的時代,Emit?生成的 IL 是可以加 debugging symbol 的,也就是 IL 到 C# 源代碼的 mapping。但 .NET Core 中的這個 API 被砍掉了(估計被 cut scope 了吧),因此不能用 IDE 在生成的 IL 里面加斷點,也不能在 exception 的 stack trace 里面看到行號。這就導致一旦 generated code 半當中 throw exception,就只能靠二分法來定位行號。
而具體二分法的操作方法是在代碼中插入斷點,這有點類似于 JavaScript 的?debugger?語句,執行到的時候會自動將程序暫停。在 C# 中?debugger?語句對應的方法是?System.Diagnostics.Debugger.Break()。當然,對于動態生成的 IL 來說這也是要 emit 的而不是直接調用,因此實際上的代碼是:ilg.Emit(OpCodes.Call, typeof(System.Diagnostics.Debugger).GetMethod("Break"));
在找到問題行了之后就要確認問題所在了,然而這也不是一個直觀的過程。在 debug 普通 C# code 的時候,一般的做法是在問題行加斷點之后,看一下 local variable 之類的是否正常。但對于動態生成的代碼,debugger 并不能讀出的 local variable。更何況對于 MSIL 而言大多數時候我想知道的是虛擬機棧上的內容,甚至都不在 local variable 里。因此這個時候就要借助祖傳的 print 大法了(從學編程的第一天開始可以一路用到帶進棺材,真香)。ILGenerator?有一個很方便的?EmitWriteLine?方法,可以直接 print 一個 local variable。我一般的做法是先把我要看的棧頂?dup?一下,然后?GetType,把?Type?放進一個 local variable,最后打印。確定了類型無誤之后再去慢慢 print 里面的值,看是否正常。
對象「常量」
MSIL 提供了直接在 IL 中載入數值或字符串常量的功能。唯一一個可以在編譯期就確定的對象(引用類型)「常量」是各個?typeof(T)。那有什么辦法可以在代碼中使用對象「常量」呢?static field 嘛……static field 其實就是 C# 的 global variable。加個 readonly 加個 initializer,就和使用常量無異了。
這對于動態生成的代碼來說其實是更常見的一個需求。例如這個 JSON 序列化庫中會用到各式各樣的 converter,而每一個 property 用到的 converter 其實是固定死的,因此完全可以寫死在生成的代碼里。而寫死的方法與之前提到的正常寫的時候用的方法無異——在動態生成的 class 里加 static field,然后生成的代碼直接載入這個 static field。
唯一需要注意的是,在動態生成的 class 定型(調用?CreateType())前是不能往 static field 里面寫東西的,因此生成 IL 的時候必須先創建好 field 并且記錄下來每個 field 需要寫入的值,待 class 定型之后再一并通過 reflection 寫入。這導致了一個缺陷:static field 不能是只讀的,因此理論上并不是個常量。不過考慮到動態生成的 class 必須要通過 reflection 才能寫 static field,也沒必要對這一點吹毛求疵……
GC-free C# 編程
要說 C# 的高性能編程,和 C++ 比到底差在哪?C# 有豐富的多線程原語、有棧上分配、有可控的 struct layout、有 unsafe 指針操作、有開箱即用的 native call、現在甚至還有 hardware intrinsics 做 SIMD。到底有什么地方離底層語言仍有差距?
就我目前的感覺來看,差距最大的是 allocate-free(自然地也是 GC-free)的能力。雖說 C# 有 value type,可以棧上分配,但這僅僅停留在理論,實際操作有非常多的阻礙,并不像 C++ 那樣如吃飯喝水般自然。舉例來說:
假設現在棧上有一個?B?的實例和一個?C?的實例,我要對其中的?A?進行某種通用的處理?ProcessA。對于 C++ 來說,在沒有內存拷貝的情況下僅僅通過引用傳遞來做到這一點是家常便飯,但 C# 則不一定。
對于現版本的 C# 而言,B?可以比較自然的做到,因為在?B?中?A?是一個 field:
而?C?則完全無法做到,因為在?C?中?A?是一個 property。雖說平常寫的是?c.A,但實際上編譯出來的是?c.get_A()。而其中?C.get_A()?的返回值是?A?而不是?ref A,這個返回值可不是能在外部被 caller 控制的。要想讓 property 返回一個引用,就必須將 property 的類型設置成?ref T,此外這個 property 也不能有 setter。
更雪上加霜的是,C# 的 best practice 是只暴露 public property 而不是 public field。你可以翻翻看 MSDN 上 corefx 各大 class 的 public API,根本找不到任何一個 public field。就連?KeyValuePair<TKey, TValue>?都是通過 property 獲取的 key value。這意味著什么呢?在 C# 中,instance method 在編譯后的第一個參數是?this,而對于 struct 來說是?ref this。說如果我要寫諸如?kvp.Value.SomeMethod()?的代碼,那編譯器就必須先將?kvp.Value?的值復制到一個 local variable 里,再對這個 local variable 取 ref。
可能有人會覺得,那就井水不犯河水,哪怕 corefx 都是用的 public property,只要自己的高性能代碼用 public field 就好了嘛。但很多時候這是很局限的。難道自己的代碼就完全不用?Dictionary<TKey, TValue>、不用?Stream、不用?Task<T>?了嗎?這是不現實的。因此只有 corefx 全方位改動了之后才會出現更多的 C# 高性能編程。
而提升 struct 利用率這一點其實最近一直在進行(不過無關 public field,這是 one-way door,估計已經改不回來了),像是 ref return、Span<T>、ValueTask<T>?都是為了減少內存分配或者內存拷貝作出的系統性改進。而實際上,利用了這些的新 C# code 可以有非常小的 GC 壓力。各位可以在附錄的 benchmark 中關注一下內存分配這一欄:Utf8Json?是早些年以接近 0 內存分配為目標而實現的 JSON 序列化庫,在有些測試中,內存上的表現其實是不如更重、功能更多、但是享受了最新的這些優化的這個庫的。不過往遠處想的話,如果 C# 的 ref 和 struct 還要在此之上獲得更進一層的表達力,我估計就要引入類似 Rust 將生命周期作為參數傳遞的機制了。
在 C# 盡可能利用 struct 的種種優化中值得一提的是 struct enumerator。眾所周知 C# 的 iterator 是通過?IEnumerable<T>?來實現的。但?IEnumerable.GetEnumerator()?的返回值是?IEnumerator,是個接口,也就是說重載的 method 無法返回一個不裝箱的 struct。但是——誰說一定要重載了?很多人認為 C# 的?foreach?只是簡單的翻譯成幾個?IEnumerable<T>?的函數調用,但實際上這兩者是獨立的。foreach?實際上會直接調用當前類型的?GetEnumerator()(不是接口調用),也就是說你可以人為定義一個返回 struct 的?GetEnumerator()。而事實上 corefx 里的那些你所知道的 collection class 已經在大量使用這個方法了。
void*(?
在直接寫 IL 的情況下,Object?其實是可以當作類似 C++ 的?void*?用的:只要是任意的 reference type,就可以自由地存入一個?Object?field 或 local variable 然后取出,其中不涉及到任何 casting 和類型檢查。
而實際上哪怕是 value type 也是可以這么干的,只要確保底層的數據長度不大于 field 的長度。當然,這已經屬于 undefined behaviour 了。
這樣做的目的是將運行時的內存消耗降低到與數據結構的深度線性相關,而不是深度的 2 的冪。換句話說,應該用棧的方式使用內存。序列化一個嵌套結構和遍歷一棵樹的邏輯是相似的。比方說,如果一個結構內含有子結構 A 和 B,序列化完 A 之后 B 應該能重用序列化 A 時的空間。最原生的以棧的方式利用內存的辦法是調用函數。但由于需要支持異步調用,函數需要能夠重入(從上次退出的地方繼續執行,詳見下文「函數重入」章節)。函數重入對于 stackful coroutine 來說是原生的,但對于 C# 的 stackless coroutine 來說就需要額外的工作了。異步棧其實是分配在堆上的,而且每次新的異步調用都會分配新的內存而不是一次性分配全部,這不滿足 GC-free 的前提。因此最佳的方案只能是將一系列的?Object?類型的 field 當作棧來用。
而對于不定長度的 value type,目前還沒有比較好的辦法。或許用 unsafe + pointer casting 可行。
函數重入
由于 JSON 的使用場景多半會和文件操作或網絡操作共存,JSON 序列化需要支持異步調用(async/await),也就是說通過 IL 生成的函數需要支持重入。
先簡單說明一下背景。目前 coroutine 有兩大派系:stackful coroutine 和 stackless coroutine。前者的代表是 Golang,而 C# 是后者。他們的區別正如其名:coroutine 有沒有自己的棧。stackful coroutine 其實很像操作系統級別的線程,context switch 之后會完整保留棧。其好處是原生的代碼可以直接無縫接入 coroutine,編譯器也無需做額外的處理,而壞處是需要和操作系統級的線程一樣與分配棧內存,實現上要為每個平臺單獨處理 context switch,這其中還包含了 memory barrier 之類的處理。而 stackless coroutine 則直接按需將棧分配在堆上,其本質是 generator(yield return)與 callback 對接。其好處是內存粒度小,實現相對簡單(因為平臺無關),但對應地其壞處是小粒度的異步調用性能低下,以及需要編譯器的支持(意味著動態代碼生成很難做)。
在這個序列化庫中,我選擇的重入方案是將整個序列化編譯成一個巨大的函數,然后在需要重入的地方插入 label,最外面套一個巨大的 switch 語句。不過整個過程需要編譯兩次,因為 emit switch 語句的時候必須預先知道有多少個 case。但幸運的是由于同步和異步的序列化本身出于性能考量就會生成兩個獨立的函數,所以只要先生成同步函數,生成的過程中記錄一些必要信息(case 的數量),再用這些信息去生成異步函數就可以了。
編譯成一個巨大函數的代價是重入點必須在這個函數上,因此不能很好地通過函數調用利用棧空間(見上文「void*」)。
Pooled array buffer
如果仔細閱讀一下 benchmark 的話,可以注意到無論是這個利用 JIT 的序列化庫還是 corefx 自帶的?System.Text.Json,居然都是異步方法的內存分配(GC 壓力)比同步方法更少,甚至有一些測式中異步方法的耗時都能比同步方法更少!而這顯然是不科學的,因為相比同步操作,異步操作需要額外的堆分配(Task),并且還有額外的重入開銷。
導致這一現象的原因其實(我認為)是一個實現上的失誤。.NET Core 3.0 的這套 JSON API 其實并不會直接向 Stream 中寫數據,而是通過了一層 array buffer 來做中轉。這層 array buffer 的長度是可變的,因此不能在堆上作分配(C# 現在是可以堆分配類似數組的?Span<T>?的,詳情請搜索?stackalloc)。出于性能考慮,在?System.Text.Json?內部實現了一個基于 array pool 的 array buffer:PooledByteBufferWriter。然而,在通過?Stream?創建?Utf8JsonWriter?的時候ef="github.com/dotnet/coref">用的卻是 ArrayBufferWriter<byte>。這導致了同步方法每次調用都會有數組分配,反而異步方法在并發不變的情況下基本不會有新的分配。
事實上使用這套?PooledByteBufferWriter?的效果非常不錯。具體來說,有一些粒度非常小的同步操作其實并不適合直接改成異步,例如每次只往?Stream?里寫一個字節對于同步操作來說是可接受的,但對于 C# 的異步操作來說額外的開銷非常大(見「函數重入」中關于 coroutine 的討論)。這本是 stackful coroutine 的一個優勢,但 stackless coroutine 在利用類似?PooledByteBufferWriter?機制的情況下也能做到很不錯的效果。
附錄:Benchmark
代碼:?定制的 dotnet/performance/micro?(從?dotnet/performance?的一個分支修改而來)
命令:?dotnet run -c Release -f netcoreapp3.0 --filter *Json_ToStream*
總結
以上是生活随笔為你收集整理的在 .NET Core 3.0 中实现 JIT 编译的 JSON 序列化,及一些心得与随想的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 在 ABP vNext 中编写仓储单元测
- 下一篇: 征集.NET中国峰会议题