sonic :基於 JIT 技術的開源全場景高效能 JSON 庫

語言: CN / TW / HK

theme: juejin

專案倉庫:http://github.com/bytedance/sonic

sonic 是位元組跳動開源的一款 Golang JSON 庫,基於即時編譯(Just-In-Time Compilation)與向量化程式設計(Single Instruction Multiple Data)技術,大幅提升了 Go 程式的 JSON 編解碼效能。同時結合 lazy-load 設計思想,它也為不同業務場景打造了一套全面高效的 API。

自 2021 年 7 月份釋出以來, sonic 已被抖音、今日頭條等業務採用,累計為位元組跳動節省了數十萬 CPU 核。

為什麼要自研 JSON 庫

JSON(JavaScript Object Notation) 以其簡潔的語法和靈活的自描述能力,被廣泛應用於各網際網路業務。但是 JSON 由於本質是一種文字協議,且沒有類似 Protobuf 的強制模型約束(schema),編解碼效率往往十分低下。再加上有些業務開發者對 JSON 庫的不恰當選型與使用,最終導致服務效能急劇劣化。

在位元組跳動,我們也遇到了上述問題。根據此前統計的公司 CPU 佔比 TOP 50 服務的效能分析資料,JSON 編解碼開銷總體接近 10%,單個業務佔比甚至超過 40%,提升 JSON 庫的效能至關重要。因此我們對業界現有 Go JSON 庫進行了一番評估測試。

首先,根據主流 JSON 庫 API,我們將它們的使用方式分為三種: - 泛型(generic)編解碼:JSON 沒有對應的 schema,只能依據自描述語義將讀取到的 value 解釋為對應語言的執行時物件,例如:JSON object 轉化為 Go map[string]interface{}; - 定型(binding)編解碼:JSON 有對應的 schema,可以同時結合模型定義(Go struct)與 JSON 語法,將讀取到的 value 繫結到對應的模型欄位上去,同時完成資料解析與校驗; - 查詢(get)& 修改(set) :指定某種規則的查詢路徑(一般是 key 與 index 的集合),獲取需要的那部分 JSON value 並處理。

其次,我們根據樣本 JSON 的 key 數量和深度分為三個量級: - 小(small):400B,11 key,深度 3 層; - 中(medium):110KB,300+ key,深度 4 層(實際業務資料,其中有大量的巢狀 JSON string); - 大(large):550KB,10000+ key,深度 6 層。

測試結果如下:

image.png

不同資料量級下 JSON 庫效能表現

結果顯示:目前這些 JSON 庫均無法在各場景下都保持最優效能,即使是當前使用最廣泛的第三方庫 json-iterator,在泛型編解碼、大資料量級場景下的效能也滿足不了我們的需要

JSON 庫的基準編解碼效能固然重要,但是對不同場景的最優匹配更關鍵 —— 於是我們走上了自研 JSON 庫的道路。

開源庫 sonic 技術原理

由於 JSON 業務場景複雜,指望通過單一演算法來優化並不現實。於是在設計 sonic 的過程中,我們借鑑了其他領域/語言的優化思想(不僅限於 JSON),將其融合到各個處理環節中。其中較為核心的技術有三塊:JIT、lazy-load 與 SIMD 。

JIT

對於有 schema 的定型編解碼場景而言,很多運算其實不需要在“執行時”執行。這裡的“執行時”是指程式真正開始解析 JSON 資料的時間段。

舉個例子,如果業務模型中確定了某個 JSON key 的值一定是布林型別,那麼我們就可以在序列化階段直接輸出這個物件對應的 JSON 值(‘true’或‘false’),並不需要再檢查這個物件的具體型別。

sonic-JIT 的核心思想就是:將模型解釋與資料處理邏輯分離,讓前者在“編譯期”固定下來

這種思想也存在於標準庫和某些第三方 JSON 庫,如 json-iterator 的函式組裝模式:把 Go struct 拆分解釋成一個個欄位型別的編解碼函式,然後組裝並快取為整個物件對應的編解碼器(codec),執行時再加載出來處理 JSON。但是這種實現難以避免轉化成大量 interface 和 function 呼叫棧,隨著 JSON 資料量級的增長,function-call 開銷也成倍放大。只有將模型解釋邏輯真正編譯出來,實現 stack-less 的執行體,才能最大化 schema 帶來的效能收益。

業界實現方式目前主要有兩種:程式碼生成 code-gen(或模版 template)和 即時編譯 JIT。前者的優點是庫開發者實現起來相對簡單,缺點是增加業務程式碼的維護成本和侷限性,無法做到秒級熱更新——這也是程式碼生成方式的 JSON 庫受眾並不廣泛的原因之一。JIT 則將編譯過程移到了程式的載入(或首次解析)階段,只需要提供 JSON schema 對應的結構體型別資訊,就可以一次性編譯生成對應的 codec 並高效執行。

sonic-JIT 大致過程如下:

image.png

sonic-JIT 體系

  1. 初次執行時,基於 Go 反射來獲取需要編譯的 schema 資訊(AST);
  2. 結合 JSON 編解碼演算法生成一套自定義的中間程式碼 OP codes(SSA);
  3. 將 OP codes 翻譯為 Plan9 彙編(LL);
  4. 使用第三方庫 golang-asm 將 Plan 9 轉為機器碼(ASM);
  5. 將生成的二進位制碼注入到記憶體 cache 中並封裝為 go function(DL);
  6. 後續解析,直接根據 type ID (rtype.hash)從 cache 中載入對應的 codec 處理 JSON。

從最終實現的結果來看,sonic-JIT 生成的 codec 效能不僅好於 json-iterator,甚至超過了程式碼生成方式的 easyjson(見後文“效能測試”章節)。這一方面跟底層文字處理運算元的優化有關(見後文“SIMD & asm2asm”章節),另一方面來自於 sonic-JIT 能控制底層 CPU 指令,在執行時建立了一套獨立高效的 ABI(Application Binary Interface)體系:

  • 將使用頻繁的變數放到固定的暫存器上(如 JSON buffer、結構體指標),儘量避免 memory load & store;
  • 自己維護變數棧(記憶體池),避免 Go 函式棧擴充套件;
  • 自動生成跳轉表,加速 generic decoding 的分支跳轉;
  • 使用暫存器傳遞引數(當前 Go Assembly 並未支援,見“SIMD & asm2asm”章節)。

Lazy-load

對於大部分 Go JSON 庫,泛型編解碼是它們效能表現最差的場景之一,然而由於業務本身需要或業務開發者的選型不當,它往往也是被應用得最頻繁的場景。

泛型編解碼效能差僅僅是因為沒有 schema 嗎?其實不然。我們可以對比一下 C++ 的 JSON 庫,如 rappidjsonsimdjson,它們的解析方式都是泛型的,但效能仍然很好(simdjson 可達 2GB/s 以上)。標準庫泛型解析效能差的根本原因在於它採用了 Go 原生泛型——interface(map[string]interface{})作為 JSON 的編解碼物件

這其實是一種糟糕的選擇:首先是資料反序列化的過程中,map 插入的開銷很高;其次在資料序列化過程中,map 遍歷也遠不如陣列高效。

回過頭來看,JSON 本身就具有完整的自描述能力,如果我們用一種與 JSON AST 更貼近的資料結構來描述,不但可以讓轉換過程更加簡單,甚至可以實現按需載入(lazy-load)——這便是 sonic-ast 的核心邏輯:它是一種 JSON 在 Go 中的編解碼物件,用 node {type, length, pointer} 表示任意一個 JSON 資料節點,並結合樹與陣列結構描述節點之間的層級關係

image.png

sonic-ast 結構示意

sonic-ast 實現了一種有狀態、可伸縮的 JSON 解析過程:當使用者 get 某個 key 時,sonic 採用 skip 計算來輕量化跳過要獲取的 key 之前的 json 文字;對於該 key 之後的 JSON 節點,直接不做任何的解析處理;僅使用者真正需要的 key 才完全解析(轉為某種 Go 原始型別)。由於節點轉換相比解析 JSON 代價小得多,在並不需要完整資料的業務場景下收益相當可觀。

雖然 skip 是一種輕量的文字解析(處理 JSON 控制字元“[”、“{”等),但是使用類似 gjson 這種純粹的 JSON 查詢庫時,往往會有相同路徑查詢導致的重複開銷(見benchmark)。

針對該問題,sonic 在對於子節點 skip 處理過程增加了一個步驟,將跳過 JSON 的 key、起始位、結束位記錄下來,分配一個 Raw-JSON 型別的節點儲存下來,這樣二次 skip 就可以直接基於節點的 offset 進行。同時 sonic-ast 支援了節點的更新、插入和序列化,甚至支援將任意 Go types 轉為節點並儲存下來。

換言之,sonic-ast 可以作為一種通用的泛型資料容器替代 Go interface,在協議轉換、動態代理等服務場景有巨大潛力。

SIMD & asm2asm

無論是定型編解碼場景還是泛型編解碼場景,核心都離不開 JSON 文字的處理與計算。其中一些問題在業界已經有比較成熟高效的解決方案,如浮點數轉字串演算法 Ryu,整數轉字串的查表法等,這些都被實現到 sonic 的底層文字運算元中。

還有一些問題邏輯相對簡單,但是可能會面對較大數量級的文字,如 JSON string 的 unquote\quote 處理、空白字元的跳過等。此時我們就需要某種技術手段來提升處理能力。SIMD 就是這樣一種用於並行處理大規模資料的技術,目前大部分 CPU 已具備 SIMD 指令集(例如 Intel AVX),並且在 simdjson 中有比較成功的實踐。

下面是一段 sonic 中 skip 空白字元的演算法程式碼:

```

if USE_AVX2

// 一次比較比較32個字元     while (likely(nb >= 32)) {         // vmovd 將單個字元轉成YMM         __m256i x = _mm256_load_si256 ((const void )sp);         // vpcmpeqb 比較字元,同時為了充分利用CPU 超標量特性使用4 倍迴圈         __m256i a = _mm256_cmpeq_epi8 (x, _mm256_set1_epi8(' '));         __m256i b = _mm256_cmpeq_epi8 (x, _mm256_set1_epi8('\t'));         __m256i c = _mm256_cmpeq_epi8 (x, _mm256_set1_epi8('\n'));         __m256i d = _mm256_cmpeq_epi8 (x, _mm256_set1_epi8('\r'));         // vpor 融合4次結果         __m256i u = _mm256_or_si256   (a, b);         __m256i v = _mm256_or_si256   (c, d);         __m256i w = _mm256_or_si256   (u, v);         // vpmovmskb  將比較結果按位展示         if ((ms = _mm256_movemask_epi8(w)) != -1) {             _mm256_zeroupper();             // tzcnt 計算末尾零的個數N             return sp - ss + __builtin_ctzll(~(uint64_t)ms);         }         / move to next block /         sp += 32;         nb -= 32;     }     / clear upper half to avoid AVX-SSE transition penalty */     _mm256_zeroupper();

endif

```

sonic 中 strnchr() 實現(SIMD 部分)

開發者們會發現這段程式碼其實是用 C 語言編寫的 —— 其實 sonic 中絕大多數文字處理函式都是用 C 實現的:一方面 SIMD 指令集在 C 語言下有較好的封裝,實現起來較為容易;另一方面這些 C 程式碼通過 clang 編譯能充分享受其編譯優化帶來的提升。為此我們開發了一套 x86 彙編轉 Plan9 彙編的工具 asm2asm,將 clang 輸出的彙編通過 Go Assembly 機制靜態嵌入到 sonic 中。同時在 JIT 生成的 codec 中我們利用 asm2asm 工具計算好的 C 函式 PC 值,直接呼叫 CALL 指令跳轉,從而繞過 Go Assembly 不能暫存器傳參的限制,壓榨最後一絲 CPU 效能。

其它

除了上述提到的技術外,sonic 內部還有很多的細節優化,比如使用 RCU 替換 sync.Map 提升 codec cache 的載入速度,使用記憶體池減少 encode buffer 的記憶體分配,等等。這裡限於篇幅便不詳細展開介紹了,感興趣的同學可以自行搜尋閱讀 sonic 原始碼進行了解。

效能測試

我們以前文中的不同測試場景進行測試(測試程式碼見benchmark),得到結果如下:

圖片

小資料(400B,11 個 key,深度 3 層)

圖片

中資料(110KB,300+ key,深度 4 層)

圖片

大資料(550KB,10000+ key,深度 6 層)

可以看到 sonic 在幾乎所有場景下都處於領先(sonic-ast 由於直接使用了 Go Assembly 匯入的 C 函式導致小資料集下有一定效能折損)

  • 平均編碼效能較 json-iterator 提升 240% ,平均解碼效能較 json-iterator 提升 110% ;
  • 單 key 修改能力較 sjson 提升 75% 。

並且在生產環境中,sonic 中也驗證了良好的收益,服務高峰期佔用核數減少將近三分之一:

圖片

位元組某服務在 sonic 上線前後的 CPU 佔用(核數)對比

結語

由於底層基於彙編進行開發,sonic 當前僅支援 amd64 架構下的 darwin/linux 平臺 ,後續會逐步擴充套件到其它作業系統及架構。除此之外,我們也考慮將 sonic 在 Go 語言上的成功經驗移植到不同語言及序列化協議中。目前 sonic 的 C++ 版本正在開發中,其定位是基於 sonic 核心思想及底層運算元實現一套通用的高效能 JSON 編解碼介面。

近日,sonic 釋出了第一個大版本 v1.0.0,標誌著其除了可被企業靈活用於生產環境,也正在積極響應社群需求、擁抱開源生態。我們期待 sonic 未來在使用場景和效能方面可以有更多突破,歡迎開發者們加入進來貢獻 PR,一起打造業界最佳的 JSON 庫!

相關連結

專案地址:http://github.com/bytedance/sonic

BenchMark:http://github.com/bytedance/sonic/blob/main/bench.sh