字节跳动开源高性能C++ JSON库sonic-cpp

2023-01-22 0 846

原副标题:二进制颤动开放源码高操控性C++ JSON库sonic-cpp

sonic-cpp 是由二进制颤动 STE 项目组和服务项目架构项目组协力研制的这款面向全国 C++ 词汇的高效率 JSON 库,无与伦比地借助现阶段 CPU 硬体优点与向定量程式设计,大幅提高了格式化反格式化操控性,导出操控性为 rapidjson 的 2.5 倍。sonic-cpp 在二进制外部上架年来, Luzy抖音、本周一头条新闻等核心理念销售业务,总计节约了数千 CPU 核心。那时我们正式宣布对内开放源码 sonic-cpp,期望能协助更多开发人员。

Github 门牌号:

https://github.com/bytedance/sonic-cpp

为何暗鞘 JSON 导出库

在二进制颤动,有大批的销售业务须要加进 JSON 导出和校订查改,挤占的 CPU 核心理念数十分大,所相关联的力学电脑生产成本较低,在这类乙烯服务项目上JSON CPU 占比即使少于 40%。因此,提高 JSON 库的操控性对二进制颤动销售业务的生产成本强化非常重要。同时,JSON 导出库屡经预览,目前业内广为采用的 rapidjson 尽管在操控性上有了非常大的改良,但相对上周一些捷伊库(如 yyjson 和 simdjson),在导出操控性各方面仍有一定的下风。

字节跳动开源高性能C++ JSON库sonic-cpp

图 1.1 yyjson、simdjson 和 rapidjson 导出操控性对照

yyjson 和 simdjson 尽管有更快的 JSON 导出速率,但是都有各别的优点。simdjson 不全力支持修正导出后的 JSON 外部结构,在前述销售业务中难以破冰。yyjson 为的是崇尚导出操控性,采用二叉树外部结构,引致搜寻统计数据时操控性十分差。

字节跳动开源高性能C++ JSON库sonic-cpp

图1.2 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的实现:

https://github.com/DaveGamble/cJSON/blob/master/cJSON.c#L902

sonic-cpp 则通过五条向定量指令,一次处理 32 个字符,极大地提高了操控性。

格式化过程如下:

通过一条向定量 load 指令,一次读取 32 二进制到向量寄存器 YMM1;

YMM1 和另外 32 二进制(全部为“) 做比较,得到一个掩码(Mask),存放在向量寄存器 YMM2;

再通过一条 move mask 指令,把 YMM2 中的掩码规约到 GPR 寄存器 R1;

最后通过指令计算下 R1 中尾巴 0 的个数,就可以得到”的位置

但如果没有 AVX512 的 load mask 指令集,在尾部最后一次读取 32 二进制时,有可能发生内存越界,进而引起诸如 coredump 等问题。sonic-cpp 的处理方式是借助 Linux 的内存分配以页为单位的机制,通过检查所要读取的内存是否跨页来解决。只要不跨页,我们认为就算越界也是安全的。如果跨页了,则按保守的方式处理,保证正确性,极大地提高了格式化的效率。具体实现见sonic-cpp实现:

https://github.com/bytedance/sonic-cpp/blob/master/include/sonic/internal/quote.h#L256

反格式化强化

sonic-cpp 同样采用 SIMD 指令做浮点数的导出,实现方式如下图所示。

和格式化向定量类似,通过同样的向量指令得到小数点和结束符的位置,再把原始字符串通过向量减法指令,减去0, 就得到真实数值。

针对不同长度的浮点数做 benchmark 测试,可以看到导出操控性提高明显。

但我们发现,在字符串长度相对照较小(少于 4 个)的情况下,向定量操控性反而是劣化的,因为此时统计数据短,标量计算并不会有多大下风,而向定量反而须要乘加这类的重计算指令。

通过分析二进制颤动外部采用 JSON 的特征,我们发现有大批少于 4 位数的短整数,同时我们认为,浮点数位数比较长的一般是小数部分,所以我们对该方法做进一步改良,整数部分通过标量方法循环读取导出,而小数部分通过上述向定量方法加速处理,取得了十分好的效果。流程如下,具体实现见 sonic-cpp ParseNumber 实现:

https://github.com/bytedance/sonic-cpp/blob/master/include/sonic/dom/parser.h#L382

按需导出

在部分销售业务场景中,用户往往只须要 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 二进制,布局更紧凑,具体外部结构如下:

DOM树设计

sonic-cpp 的 DOM 统计数据外部结构采用类似于 rapidjson 的实现,可以对包括 array 或 object 在内的所有节点进行校订查改。

在 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。采用内存池有以下几个好处:

避免频繁地 malloc。DOM 下的 node 只有 16 byte,采用内存池可以高效率地为这些小的统计数据外部结构分配内存。

避免析构 DOM 上的每一个 node,只须要在析构 DOM 树的时候,统一释放分配器的内存即可。

Object 内建的 map 也采用了内存池分配内存,使得内存可以统一分配和释放。

操控性测试

在全力支持高效率的校订改查的基础上,操控性和 simdjson、yyjson 可比。

不同 JSON 库操控性对照

基准测试是在 https://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 库!

OSCHINA 2022中国开放源码开发人员问卷启动

你的反馈很重要!

立即参与

END

Linux 的吉祥物为何是一只企鹅?

这里有最新开放源码资讯、软件预览、技术干货等内容

点这里 ↓↓↓ 记得 关注✔ 标星⭐ 哦~

相关文章

发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务