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