性能提升2.5倍!字節開源高性能C++ JSON庫sonic-cpp

語言: CN / TW / HK

sonic-cpp 是由字節跳動 STE 團隊和服務框架團隊共同研發的一款面向 C++ 語言的高效 JSON 庫,極致地利用當前 CPU 硬件特性與向量化編程,大幅提高了序列化反序列化性能,解析性能為 rapidjson 的 2.5 倍。 sonic-cpp 在字節內部上線以來, 已為抖音、今日頭條等核心業務,累計節省了數十萬 CPU 核心。近日,我們正式對外開源 sonic-cpp,希望能夠幫助更多開發者,歡迎大家star、fork。

Github:http://github.com/bytedance/sonic-cpp

為什麼自研 JSON 解析庫

在字節跳動,有大量的業務需要用到 JSON 解析和增刪查改,佔用的 CPU 核心數非常大,所對應的物理機器成本較高,在某些單體服務上JSON CPU 佔比甚至超過 40%。因此,提升 JSON 庫的性能對於字節跳動業務的成本優化至關重要。同時,JSON 解析庫幾經更新,目前業界廣泛使用的 rapidjson 雖然在性能上有了很大的改進,但相較於近期一些新的庫(如 yyjsonsimdjson),在解析性能方面仍有一定的劣勢。

圖 1.1 yyjson、simdjson 和 rapidjson 解析性能對比

圖片來源: http://github.com/ibireme/yyjson

yyjson 和 simdjson 雖然有更快的 JSON 解析速度,但是都有各自的缺點。simdjson 不支持修改解析後的 JSON 結構,在實際業務中無法落地。yyjson 為了追求解析性能,使用鏈表結構,導致查找數據時性能非常差。

圖1.2 yyjson數據結構

圖片來源自: http://github.com/ibireme/yyjson

基於上述原因,為了降低物理成本、優化性能,同時利用字節跳動已開源 Go JSON 解析庫 sonic-go 的經驗和部分思路,STE ****團隊和服務框架團隊合作自研了一個適用於 C/C++ 服務的 JSON 解析庫 sonic-cpp

sonic-cpp 主要具備以下特性:

  • 高效的解析性能,其性能為 rapidjson 的 2.5 倍

  • 解決 yyjson 和 simdjson 各自的缺點,支持高效的增刪改查

  • 基本上支持 json 庫常見的所有接口,方便用户遷移

  • 在字節跳動商業化廣告、搜索、推薦等諸多中台業務中已經大規模落地,並通過了工程化的考驗

sonic-cpp 優化原理

sonic-cpp 在設計上整合了 rapidjson ,yyjson 和 simdjson 三者的優點,並在此基礎上做進一步的優化。在實現的過程中,我們主要通過充分利用向量化(SIMD)指令、優化內存佈局和按需解析等關鍵技術,使得序列化、反序列化和增刪改查能達到極致的性能。

向量化優化(SIMD)

單指令流多數據流(Single Instruction Multiple Data,縮寫:SIMD)是一種採用一個控制器來控制多個處理器,同時對一組數據中的每一個數據分別執行相同的操作,從而實現空間上的並行性技術。例如 X86 的 SSE 或者 AVX2 指令集,以及 ARM 的 NEON 指令集等。sonic-cpp 的核心優化之一,正是通過利用 SIMD 指令集來實現的。

序列化優化

從 DOM 內存表示序列化到文件的過程中,一個非常重要的過程是做字符串的轉義,比如在引號前面添加轉義符`` 。比如,把This is "a" string 序列化成 "This is "a" string" ,存放在文件。常見的實現是逐個字符掃描,添加轉義,比如 cJson 的實現

sonic-cpp 則通過五條向量化指令,一次處理 32 個字符,極大地提高了性能。

序列化過程如下:

  1. 通過一條向量化 load 指令,一次讀取 32 字節到向量寄存器 YMM1;

<!---->

  1. YMM1 和另外 32 字節(全部為) 做比較,得到一個掩碼(Mask),存放在向量寄存器 YMM2;

  2. 再通過一條 move mask 指令,把 YMM2 中的掩碼規約到 GPR 寄存器 R1;

  3. 最後通過指令計算下 R1 中尾巴 0 的個數,就可以得到的位置

但如果沒有 AVX512 的 load mask 指令集,在尾部最後一次讀取 32 字節時,有可能發生內存越界,進而引起諸如 coredump 等問題。 sonic-cpp 的處理方式是利用 Linux 的內存分配以頁為單位的機制,通過檢查所要讀取的內存是否跨頁來解決。只要不跨頁,我們認為就算越界也是安全的。如果跨頁了,則按保守的方式處理,保證正確性,極大地提高了序列化的效率。具體實現見 sonic-cpp 實現

反序列化優化

在 JSON 的反序列化過程中,同樣有個非常重要的步驟是解析數值,它對解析的性能至關重要。比如把字符串"12.456789012345" 解析成浮點數 12.456789012345。常見的實現基本上是逐個字符解析,見 Rapidjson 的實現 ParseNumber

sonic-cpp 同樣採用 SIMD 指令做浮點數的解析,實現方式如下圖所示。

和序列化向量化類似,通過同樣的向量指令得到小數點和結束符的位置,再把原始字符串通過向量減法指令,減去'0', 就得到真實數值。

當我們確定了小數點和結束符的位置,以及向量寄存器中存放的 16 個原始數值,通過乘加指令把他們拼成最終的 12456789012345和指數 12

針對不同長度的浮點數做 benchmark 測試,可以看到解析性能提升明顯。

但我們發現,在字符串長度相對比較小(少於 4 個)的情況下,向量化性能反而是劣化的,因為此時數據短,標量計算並不會有多大劣勢,而向量化反而需要乘加這類的重計算指令。

通過分析字節跳動內部使用 JSON 的特徵,我們發現有大量少於 4 位數的短整數,同時我們認為,浮點數位數比較長的一般是小數部分,所以我們對該方法做進一步改進,整數部分通過標量方法循環讀取解析,而小數部分通過上述向量化方法加速處理,取得了非常好的效果。流程如下,具體實現見 sonic-cpp ParseNumber 實現

按需解析

在部分業務場景中,用户往往只需要 JSON 中的少數目標字段,此時,全量解析整個 JSON 是不必要的。為此,sonic-cpp 中實現了高性能的按需解析接口,能根據給定的 JsonPointer(目標字段的在 JSON 中的路徑表示) 解析 JSON 中的目標字段。在按需解析時,由於JSON 較大,核心操作往往是如何跳過不必要的字段。如下。

傳統實現

JSON 是一種半結構化數據,往往有嵌套 object 和 array。目前,實現按需解析主要有兩種方法:遞歸下降法和兩階段處理。遞歸下降法,需要遞歸下降地“解析”整個 JSON,跳過所有不需要的 JSON 字段,該方法整體實現分支過多,性能較差;兩階段處理需要在階段一標記整個 JSON token 結構的位置,例如,}]等,在階段二再根據 token 位置信息,線性地跳過不需要的 JSON 字段,如按需查找的字段在 JSON 中的位置靠前時,該方法性能較差。

sonic-cpp 實現

sonic-cpp 基於 SIMD 實現了高性能的單階段的按需解析。在按需解析過程中,核心操作在於如何跳過不需要的 JSON object 或 array。sonic-cpp 充分利用了完整的 JSON object 中 左括號數量必定等於右括號數量這一特性,利用 SIMD 讀取 64 字節的 JSON 字段,得到左右括號的 bitmap。進一步,計算 object 中左括號和右括號的數量,最後通過比較左右括號數量來確定 object 結束位置。具體操作如下:

經過全場景測試,sonic-cpp 的按需解析明顯好於已有的實現。性能測試結果如下圖。其中,rapidjson-sax 是基於 rapidjson 的 SAX 接口實現的,使用遞歸下降法實現的按需解析。simdjson 的按需解析則是基於兩階段處理的方式實現。Normal,Fronter,NotFoud 則分別表示,按需解析時,目標字段 在 JSON 中的位置居中,靠前或不存在。不過,使用 sonic-cpp 和 simdjson 的按需解析時,都需要保證輸入的 JSON 是正確合法的。

按需解析擴展

sonic-cpp 利用 SIMD 前向掃描,實現了高效的按需解析。在字節跳動內部,這一技術還可以應用於兩個 JSON 的合併操作。在合併 JSON 時,通常需要先解析兩個 JSON,合併之後,再反序列化。但是,如果兩個 JSON 中需要合併的字段較少,就可以使用按需解析思想,先將各個字段的值解析為 raw JSON 格式,然後再進行合併操作。這樣,能極大地減少 JSON 合併過程中的解析和序列化開銷。

DOM 設計優化

節點設計

在 sonic-cpp 中,表示一個 JSON value 的類被稱作 node。node 採用常見的方法,將類型和 size 的信息合為一個,只使用 8 字節,減少內存的使用。對於每個 node,內存上只需要 16 字節,佈局更緊湊,具體結構如下:

image.png

DOM樹設計

sonic-cpp 的 DOM 數據結構採用類似於 rapidjson 的實現,可以對包括 array 或 object 在內的所有節點進行增刪查改。

image.png

在 DOM 的設計上,sonic-cpp 把 object 和 array 的成員以數組方式組織,保證其在內存上的連續。數組方式讓 sonic-cpp 隨機訪問 array 成員的效率更高。而對於 object,sonic-cpp 為其在 meta 數據中保存一個 map。map 裏保存了 key 和 value 對應的 index。通過這個 map,查找的複雜度由 O(N) 降到 O(logN)。sonic-cpp 為這個 map 做了一定的優化處理:

  • 按需創建: 只在調用接口時才會生成這個 map,而不是解析的時候創建。

  • 使用 string_view 作為 key: 無需拷貝字符串,減少開銷。

內存池

sonic-cpp 提供的內存分配器默認使用內存池進行內存分配。該分配器來自 rapidjson。使用內存池有以下幾個好處:

  1. 避免頻繁地 malloc。DOM 下的 node 只有 16 byte,使用內存池可以高效地為這些小的數據結構分配內存。

  2. 避免析構 DOM 上的每一個 node,只需要在析構 DOM 樹的時候,統一釋放分配器的內存即可。

Object 內建的 map 也使用了內存池分配內存,使得內存可以統一分配和釋放。

性能測試

在支持高效的增刪改查的基礎上,性能和 simdjson、yyjson 可比。

不同 JSON 庫性能對比

基準測試是在 http://github.com/miloyip/nativejson-benchmark 的基礎上支持 sonic-cpp 和 yyjson,測試得到。

反序列化(Parse)性能基準測試結果

序列化(Stringify)性能基準測試結果:

不同場景性能對比

sonic-cpp 與 rapidjson,simdjson 和 yyjson 之間在不同場景的性能對比(HIB: Higher is better)。

生產環境中性能對比

在實際生產環境中,sonic-cpp 的性能優勢也得到了非常好的驗證,下面是字節跳動抖音某個服務使用 sonic-cpp 在高峯段 CPU 前後的對比。

展望

sonic-cpp 當前僅支持 amd64 架構,後續會逐步擴展到 ARM 等其它架構。同時,我們將積極地支持 JSON 相關 RFC 的特性,比如,支持社區的 JSON 合併相關的 RFC 7386,依據 RFC 8259 設計 JSON Path 來實現更便捷的 JSON 訪問操作等。

歡迎開發者們加入進來貢獻 PR,一起打造業界更好的 C/C++ JSON 庫!

直播預告

為了幫助大家更好地理解 sonic-cpp,我們將於2022年12月15日19:30在《掘金公開課18期》,與大家直播分享 sonic-cpp 的技術原理、實踐效果和未來規劃。參與直播互動還有機會贏取周邊禮品哦!禮品多多,歡迎大家關注並掃描下方二維碼預約直播。

直播互動禮品圖片