sonic :基于 JIT 技术的开源全场景高性能 JSON 库

业界实现方式目前主要有两种:代码生成 code-gen(或模版 template)和 即时编译 JIT。前者的优点是库开发者实现起来相对简单,缺点是增加业务代码的维护成本和局限性,无法做到秒级热更新——这也是代码生成方式的 JSON 库受众并不广泛的原因之一。JIT 则将编译过程移到了程序的加载(或首次解析)阶段,只需要提供 JSON schema 对应的结构体类型信息,就可以一次性编译生成对应的 codec 并高效执行。

sonic-JIT 大致过程如下:

4e0cb6b7f49b08b9b1999bb4d9ce60de.png

sonic-JIT 体系

  1. 初次运行时,基于 Go 反射来获取需要编译的 schema 信息;

  2. 结合 JSON 编解码算法生成一套自定义的中间代码 OP codes;

  3. 将 OP codes 翻译为 Plan9 汇编;

  4. 使用第三方库 golang-asm 将 Plan 9 转为机器码;

  5. 将生成的二进制码注入到内存 cache 中并封装为 go function;

  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 库,如 rappidjson、simdjson,它们的解析方式都是泛型的,但性能仍然很好(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 数据节点,并结合树与数组结构描述节点之间的层级关系

d1eba71c3a30889103fbbfa396e0c052.png

sonic-ast 结构示意

sonic-ast 实现了一种有状态、可伸缩的 JSON 解析过程:当使用者 get 某个 key 时,sonic 采用 skip 计算来轻量化跳过要获取的 key 之前的 json 文本;对于该 key 之后的 JSON 节点,直接不做任何的解析处理;仅使用者真正需要的 key 才完全解析(转为某种 Go 原始类型)。由于节点转换相比解析 JSON 代价小得多,在并不需要完整数据的业务场景下收益相当可观。

虽然 skip 是一种轻量的文本解析(处理 JSON 控制字符“[”、“{”等),但是使用类似 gjson 这种纯粹的 JSON 查找库时,往往会有相同路径查找导致的重复开销。

针对该问题,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 源码进行了解。

性能测试


我们以前文中的不同测试场景进行测试,得到结果如下:

7237eee4173ff5cfa8bb26dd011d5bf3.png

小数据(400B,11 个 key,深度 3 层)

9c682aa4dade65b173afd12475b81486.png

中数据(110KB,300+ key,深度 4 层)

31456fe20e947b1aba0fce0a5cad4fb1.png

大数据(550KB,10000+ key,深度 6 层)

可以看到 sonic 在几乎所有场景下都处于领先(sonic-ast 由于直接使用了 Go Assembly 导入的 C 函数导致小数据集下有一定性能折损)

  • 平均编码性能较 json-iterator 提升 240% ,平均解码性能较 json-iterator 提升 110% ;

  • 单 key 修改能力较 sjson 提升 75% 。

并且在生产环境中,sonic 中也验证了良好的收益,服务高峰期占用核数减少将近三分之一:

9d752131e6a70d12270b2bcc16301acd.png

字节某服务在 sonic 上线前后的 CPU 占用(核数)对比

结语

总结

最后小编想说:不论以后选择什么方向发展,目前重要的是把Android方面的技术学好,毕竟其实对于程序员来说,要学习的知识内容、技术有太多太多,要想不被环境淘汰就只有不断提升自己,从来都是我们去适应环境,而不是环境来适应我们!

这里附上我整理的几十套腾讯、字节跳动,京东,小米,头条、阿里、美团等公司19年的Android面试题。把技术点整理成了视频和PDF(实际上比预期多花了不少精力),包含知识脉络 + 诸多细节。

由于篇幅有限,这里以图片的形式给大家展示一小部分。

网上学习 Android的资料一大堆,但如果学到的知识不成体系,遇到问题时只是浅尝辄止,不再深入研究,那么很难做到真正的技术提升。希望这份系统化的技术体系对大家有一个方向参考。

技术进阶之路很漫长,一起共勉吧~
《Android学习笔记总结+移动架构视频+大厂面试真题+项目实战源码》点击传送门,即可获取!
由于篇幅有限,这里以图片的形式给大家展示一小部分。

[外链图片转存中…(img-8lPVnlxv-1715874785504)]

网上学习 Android的资料一大堆,但如果学到的知识不成体系,遇到问题时只是浅尝辄止,不再深入研究,那么很难做到真正的技术提升。希望这份系统化的技术体系对大家有一个方向参考。

技术进阶之路很漫长,一起共勉吧~
《Android学习笔记总结+移动架构视频+大厂面试真题+项目实战源码》点击传送门,即可获取!

  • 18
    点赞
  • 16
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值