1 sonic产生的背景

为什么要优化?
json操作在服务中的cpu开销中占据相当的比重。根据字节所有服务的统计,json序列化和反序列化的开销接近10%,部分服务甚至达到40%。
golang现有的库中没有一个可以在全场景下保持优异的性能,即使是json-iterator,在泛型编解码、大数据量级场景下的性能也会下降。与其他语言相比,golang的各种json库速度都慢了很多,存在优化空间。

适用服务场景和降本收益
由于sonic优化的是json操作,所以在json操作的cpu开销占比较大的服务场景中收益会比较明显。比如网关、转发和入口服务等。
截止2022年1月份,sonic已应用于抖音,今日头条等服务,累计为字节节省了数十万核。下图为字节某服务使用sonic后高峰时段的cpu占用核数对比(图来源)。

2 快速试用sonic

2.1 较小侵入的使用方法

想要了解sonic会对自己的服务产生多大的性能提升,评估是否值得切换,可以下面的方式较小侵入地将当前使用的json库切换为sonic:使用github.com/brahma-adshonor/gohook,在main函数的入口处hook当前使用的json库函数为sonic中对等的函数。

import "github.com/brahma-adshonor/gohook"func main() {// 在main函数的入口hook当前使用的json库(如encoding/json)gohook.Hook(json.Marshal, sonic.Marshal, nil)gohook.Hook(json.Unmarshal, sonic.Unmarshal, nil)
}

从上面可以看到,hook是函数级的,因此可以具体验证具体函数的性能提升,也可以部分函数使用sonic(出于对某些函数的不信任、或者自己有性能更优异或更稳定的实现)。
关于gohook
github.com/brahma-adshonor/gohook的大概实现是向被hook的函数地址中写入跳转指令,直接跳转到新的函数地址。
需要注意的是,gohook未经过生产环境验证,建议仅测试使用。

2.2 注意事项

key排序
sonic在序列化时默认是不对key进行排序的。json的规范也与顺序无关,但若需要json是有序的,可以在序列化时选择排序的配置,大约会带来10%的性能损耗。排序方法如下:

import "github.com/bytedance/sonic"
import "github.com/bytedance/sonic/encoder"// Binding map only
m := map[string]interface{}{}
v, err := encoder.Encode(m, encoder.SortMapKeys)// Or ast.Node.SortKeys() before marshal
var root := sonic.Get(JSON)
err := root.SortKeys()

HTML Escape
默认不开启html Escape,因为会造成约15%的性能损耗,若需要开启,可以通过下面的方法:

import "github.com/bytedance/sonic"v := map[string]string{"&&":"<>"}
ret, err := Encode(v, EscapeHTML) // ret == `{"\u0026\u0026":{"X":"\u003c\u003e"}}`

3 sonic的内部实现

golang为了提高编译速度,在编译时做的优化较少,而其他语言(如C语言使用clang或gcc编译)编译时可以获得深度的优化。sonic的核心技术点就是使用C语言编写热点操作,使用Clang的深度优化编译选项编译后供golang调用。

这里借鉴了 json-iterator 的组装各类型处理函数的实现,不同之处在于直接编译出来,减少了函数调用的开销。
simd-json也使用了simd,但是使用的是go的编译器,相比clang所做的优化更少。
为什么不使用cgo
使用cgo,可以直接用golang编译并调用C代码。import虚拟package C,并在注释中include C代码文件、声明C中实现的函数。

/*
#include "hello.c"
int SayHello();
double Sum();
*/
import "C"

即可在golang代码中通过C包名调用上面声明的C函数。编译命令与编译只包含go文件的编译命令相同(如go build main.go)。

cgo也可以对C代码进行O3级别的优化。

与sonic相比,cgo的实现更加简便,也对代码进行了深度优化,似乎是一个更好的方案。但是cgo在调用c代码的时候引入了调度、切换线程栈等开销,会造成较大(有的场景中高达20多倍)的性能损耗。
golang也可以直接编译C语言代码,可以通过命令行go tool 6c -I $GOROOT/src/pkg/runtime -S add.c进行编译(见参考文献5,未尝试过,待验证)。

3.1 热点操作编译成汇编

以序列化为例。序列化时有int转字符串、float转字符串等cpu消耗较高的操作,将这些函数使用C语言编写(native目录)。

3.1.1 代码级优化

3.1.1.1 SIMD

以查找前缀类空格字符个数的lspace的部分SSE代码为例,加载16字节(_mm_load_si128)到变量x,生成16个字节的类空格字符(_mm_set1_epi8)临时变量,比较16字节变量x和全是空格的临时变量(_mm_cmpeq_epi8),…

    /* 16-byte loop */while (likely(nb >= 16)) {__m128i x = _mm_load_si128 ((const void *)sp);__m128i a = _mm_cmpeq_epi8 (x, _mm_set1_epi8(' '));__m128i b = _mm_cmpeq_epi8 (x, _mm_set1_epi8('\t'));__m128i c = _mm_cmpeq_epi8 (x, _mm_set1_epi8('\n'));__m128i d = _mm_cmpeq_epi8 (x, _mm_set1_epi8('\r'));__m128i u = _mm_or_si128   (a, b);__m128i v = _mm_or_si128   (c, d);__m128i w = _mm_or_si128   (u, v);/* check for matches */if ((ms = _mm_movemask_epi8(w)) != 0xffff) {return sp - ss + __builtin_ctz(~ms);}/* move to next block */sp += 16;nb -= 16;}

根据预设条件(字符串长度、float精度),动态选择使用向量化编程或标量编程。

3.1.1.2 loop unrolling

上面的代码中,针对剩余字符的长度nb,以16字节为一组做loop unrolling,不足16字节的部分再逐字节匹配。

为什么要在编码阶段做?
若编译器在编译阶段即可知道循环次数,会自动做loop unrolling,此处因为字符串的长度不可知,编译器不知如何优化,因此在编码阶段实现。
编译器的优化可以做到非常极致,下面是我自己验证的一段C代码

unsigned int sum(void) {unsigned int sum = 0;for (unsigned int i = 0;i < 32;i++) {sum += i;}return sum;
}

编译器可以做到直接优化到直接返回运算结果0x1f0

0000000000400650 <_Z3sumv>:400650:       b8 f0 01 00 00          mov    $0x1f0,%eax400655:       c3                      retq400656:       66 2e 0f 1f 84 00 00    nopw   %cs:0x0(%rax,%rax,1)40065d:       00 00 00

3.1.2 编译

sonic当前支持avx、avx2和sse三个向量指令集,编译命令如下:

# AVX2
clang -mno-red-zone -arch x86_64 -fno-asynchronous-unwind-tables -fno-builtin -fno-exceptions -fno-rtti -fno-stack-protector -nostdlib -O3 -Wall -Werror -msse -mno-sse4 -mavx -mno-avx2 -DUSE_AVX=1 -DUSE_AVX2=0 -S -o output/avx/native.s native/native.c
# AVX
clang -mno-red-zone -arch x86_64 -fno-asynchronous-unwind-tables -fno-builtin -fno-exceptions -fno-rtti -fno-stack-protector -nostdlib -O3 -Wall -Werror -msse -mno-sse4 -mavx -mavx2    -DUSE_AVX=1 -DUSE_AVX2=1  -S -o output/avx2/native.s native/native.c
# SSE
clang -mno-red-zone -arch x86_64 -fno-asynchronous-unwind-tables -fno-builtin -fno-exceptions -fno-rtti -fno-stack-protector -nostdlib -O3 -Wall -Werror -msse -mno-sse4 -mno-avx -mno-avx2 -S -o output/sse/native.s native/native.c

编译器使用的是Clang,O3优化级别,以尽可能提高性能。
有人或许会想到如果使用AVX-512指令集是否能够进一步优化,sonic团队已经做出了验证,使用后会因为cpu降频导致性能恶化。不过有人提出intel的最新平台(Icelake or SPR)已经解决了降频问题,还没有最终定论(见https://github.com/bytedance/sonic/issues/319)。

3.1.2 汇编转换

由于clang编译出来的是x86汇编,而golang编译出来的是plan9汇编。为了在golang中调用clang编译出来的汇编,字节开发了一个工具(tools/asm2asm)将x86的汇编转换为plan9。

#AVX2
python3 tools/asm2asm/asm2asm.py internal/native/avx2/native_amd64.s output/avx2/native.s
#AVX
python3 tools/asm2asm/asm2asm.py internal/native/avx/native_amd64.s output/avx/native.s
#SSE
python3 tools/asm2asm/asm2asm.py internal/native/sse/native_amd64.s output/sse/native.s

x86汇编转plan9汇编的另一个开源方案c2goasm。

3.2 运行时汇编

序列化和反序列化操作包含前面提到的热点操作和其他操作,完整的解析需要将这些操作的汇编整合到一起。

3.2.1 整理操作序列

若为基础类型,则直接添加汇编对应的index(_Op)序列;否则,则递归调用,并添加"{“,”:“,”,"等字符对应的操作序列。对应的代码为_Compiler。

3.2.2 汇编

根据第1步生成的操作序列取出操作(对照表为_OpFuncTab),并在前后添加调用需要的压栈出栈等操作,添加了一些注释如下:

func (self *BaseAssembler) build() {self.o.Do(func() {self.init()self.f()// f为函数指针,对应每个golang版本的compile函数self.validate()self.assemble() // 汇编为字节码self.resolve()self.release()})
}

golang版本差异
不同的golang版本需要添加的操作序列有所不同,分别位于assemble_amd64_go117.go和assemble_amd64_go116.go两个文件中。通过golang的编译开关在编译阶段控制,方法为在文件的第一行配置,如:// +build go1.15,!go1.17,则该文件仅在golang版本为1.15何1.16时会参与编译。

向量指令集差异
5.1编译为SSE、AVX和AVX2均生成了汇编,在程序启动时获取cpu当前支持的指令集,并按照AVX2、AVX、SSE的优先级选择

import    `github.com/klauspost/cpuid/v2`var (HasAVX  = cpuid.CPU.Has(cpuid.AVX)HasAVX2 = cpuid.CPU.Has(cpuid.AVX2)HasSSE = cpuid.CPU.Has(cpuid.SSE)
)

在启动时

compile函数如下:

func (self *_Assembler) compile() {self.prologue() // 压栈等操作self.instrs() // 操作index序列转化为对应的汇编操作self.epilogue() // 出栈等操作self.copy_string()self.escape_string()self.escape_string_twice()self.type_error()self.field_error()self.range_error()self.stack_error()self.base64_error()self.parsing_error()
}

然后使用golang的汇编工具(github.com/twitchyliquid64/golang-asm,fork自golang)汇编成字节码,字节码保存在BaseAssembler的成员变量c中。

3.2.3 设置为程序段

Loader位字节码申请一段内存,将BaseAssembler.c的内容复制到新申请的内存中,并设置为只读可执行。

func (self Loader) LoadWithFaker(fn string, fp int, args int, faker interface{}) (f Function) {p := os.Getpagesize()n := (((len(self) - 1) / p) + 1) * p/* register the function */m := mmap(n)v := fmt.Sprintf("runtime.__%s_%x", fn, m)argsptr, localsptr := stackMap(faker)registerFunction(v, m, uintptr(n), fp, args, uintptr(len(self)), argsptr, localsptr)/* reference as a slice */s := *(*[]byte)(unsafe.Pointer(&reflect.SliceHeader {Data : m,Cap  : n,Len  : len(self),}))/* copy the machine code, and make it executable */copy(s, self)mprotect(m, n) // 设置为只读可执行return Function(&m)
}

3.2.4 RCU cache

每个结构体对应的序列化/反序列化字节码可以缓存起来,后面直接调用,以减少运行时汇编操作的执行次数(缓存足够大的时候只需要执行一次)。
微服务场景涉及到的json结构的数量不会很多,缓存是一个典型的读多写少,且元素较少的场景,而golang的sync.map在该场景下性能并非最优。sonic使用open-addressing-hash + RCU技术实现了一个高性能并发安全的cache。

open-addressing-hash cache
使用一个slice实现,使用open-addressing-hash保存结构体对应的字节码地址

type _ProgramMap struct {n uint64 // 当前元素个数m uint32 // maskb []_ProgramEntry // 保存结构体操作结构体的的slice
}// 字节码地址
type _ProgramEntry struct {vt *rt.GoType // 变量类型fn interface{} // 字节码地址
}

_ProgramMap.m应该是mask的简称,用于将结构体的hash映射到_ProgramMap.b的index,代码中为p := vt.Hash & self.m,实际上取余操作,效率更高。使用这种方法需要对应的二进制取值全部为1,这也是为什么注释中说明slice的长度必须是的整数次幂的原因。该技巧在sonic库中多次应用,如C代码fastbytes.c:34遍历到向量操作字节对齐位置的判断条件的操作 while (nb > 0 && ((uintptr_t)sp & ALIGN_MASK))
RCU
1)读操作
使用sync/atomic原子加载_ProgramMap.b的地址,根据open-addressing-hash的规则,先判断首次hash的位置是否保存的是当前结构体的字节码,若不是则顺序依次判断_ProgramMap.b这个slice后面的元素。

func (self *ProgramCache) Get(vt *rt.GoType) interface{} {return (*_ProgramMap)(atomic.LoadPointer(&self.p)).get(vt)
}

2)写操作
写操作使用mutex加锁保护,实现为原子加载_ProgramMap.b地址并赋值一份,然后在复制出来的slice中按照open-addressing-hash插入,然后将复制出来的slice原子赋值给_ProgramMap.b。

atomic.StorePointer(&self.p, unsafe.Pointer((*_ProgramMap)(atomic.LoadPointer(&self.p)).add(vt, val)))func (self *_ProgramMap) add(vt *rt.GoType, fn interface{}) *_ProgramMap {p := self.copy()f := float64(atomic.LoadUint64(&p.n) + 1) / float64(p.m + 1)/* check for load factor */if f > _LoadFactor {p = p.rehash()}/* insert the value */p.insert(vt, fn)return p
}

这里有一个check for load factor的操作,是在_ProgramMap.b中存储的元素个数占总容量的比例超过_LoadFactor时,重新为其申请更大的空间并重新hash填写元素,防止写满slice,也为了避免_LoadFactor过大影响检索性能。

写操作写的过程中没有修改读操作使用的_ProgramMap.b,读的时候无需持锁,性能会更高;写操作之间是通过mutex隔离的,所以写也是并发安全的。

3.2.5 其他优化

由于汇编函数不能内联到go函数中,函数调用引入的开销甚至会抵消SIMD带来的性能提升。因此sonic使用了一下优化:
a.全局函数表+函数offset
b.使用寄存器传参
c.无栈内存管理

3.3 懒加载

针对泛型编解码,基于map开销较大的考虑,sonic实现了更能符合json结构的树形AST;针对部分解析,使用了懒加载技术;并且以一种更自适应和有效的方式处理多字段查询的场景。

4 进一步优化

4.1 结构体已知场景的进一步优化

对于json对应的结构体已知的服务场景,可以在预先生成好汇编后的字节码,避免运行时编译,且可以不用引入JIT机制。还有一个好处,就是汇编可以离线完成,而汇编是比较耗时的,在线进行会导致首次请求耗时较高。

4.2 其他cpu密集型操作的优化

对于其他cpu密集性的操作,也可以通过编写高性能的C代码并经过优化编译后供golang直接调用。到这里好像是在野路子上越走越远,实际上Go源码中的一些cpu密集型操作也编译成了汇编,如 crypto 和 math。
如果类似json操作需要在运行时组装,可以在sonic的代码架构上做修改;
如果不可分割的操作,可以仅使用其中将clang编译出的汇编转换成plan9汇编的工具。类似的开源工具还有c2goasm。

5 总结

本文介绍了字节golang json库sonic的产生背景和线上降本收益、应用场景和使用注意事项、实现和引申的优化思考,希望对当下如火如荼的降本提供一个新的思路。

参考资料

1.sonic
2.gohook
3.sonic:基于 JIT 技术的开源全场景高性能 JSON 库
4.c2goasm
5.Golang调用汇编
6.simd-instructions-in-go
7.go-simd
8.what-is-sse-and-avx

为字节节省数十万核的json库sonic相关推荐

  1. Py之Crawler:基于requests库+json库实现爬取刘若英2018导演电影《后来的我们》的插曲《再见》张震岳的几十万热评+词云:发现“再见”亦是再也不见

    Py之Crawler:基于requests库+json库实现爬取刘若英2018导演电影<后来的我们>的插曲<再见>张震岳的几十万热评+词云:发现"再见"亦是 ...

  2. 英国继银行被窃之后 信贷公司Wonga数十万客户数据被泄

    英国知名的发薪日贷款(Payday Loan)公司Wonga上周日确定遭遇数据泄露,之后发表声明通知客户联系银行. Wonga在声明中指出,黑客可能非法访问了数十万账户的个人信息,关系到预计总计高达2 ...

  3. 创办智能车竞赛平台,十五年无间断,育人数十万

    ➤01 初稿 一.竞赛简介   全国大学生智能汽车竞赛是以智能汽车为研究对象,面向全国大学生开展的复杂工程探索类别的科技竞赛,目标是提高学生的动手实践能力.探索创新兴趣.团队协作精神.它起源于2005 ...

  4. 借助Docker单机秒开数十万TCP连接

    熟悉网络编程的都清楚系统只有65535个端口可用,1024以下的端口为系统保留,所以除去系统保留端口后可用的只有65411个端口,而一个TCP连接由TCP四元组(源IP.源端口.TCP.目标IP.目标 ...

  5. WWW 2021 | Radflow: 可进行数十万节点的多变量时序预测模型

    ©作者 | 方雨晨 学校 | 北京邮电大学 研究方向 | 时空数据挖掘 此文使用与 N-BEATS 一样的层级循环神经网络捕捉不同的时间趋势.然后将循环神经网络的输出做空间消息传递.在超大的网络时序数 ...

  6. 数十万应用结点全息监控,ARMS新上线的应用监控神器到底有多牛?

    摘要: 就在不久前,2017年阿里双11刚刚创下电商史上的新销售奇迹,24小时交易金额达1682亿,每秒交易创建峰值325000,每秒支付峰值256000!在这个海量交易背后是数十万个结点规模的应用的 ...

  7. 这份数十万人浏览,作为企业风向标的BI报告,你一定要看看

    近日,Gartner发布了2020年度的BI商业智能和分析平台魔力象限报告.作为业内的权威报告,魔力象限在厂商和用户中受到了非常广泛的关注.与往年类似,Gartner在本报告中预测了2022~2025 ...

  8. 新网数十万域名管理密码泄露

    漏洞概要 关注数(5) 关注此漏洞 缺陷编号: WooYun-2011-03697 漏洞标题: 新网数十万域名管理密码泄露 相关厂商: 新网 漏洞作者: moxie 提交时间: 2011-12-21 ...

  9. socket io 不使用redis_为什么Redis单线程能够达到数十万、百万级的QPS?

    性能测试报告 查看了下阿里 Redis 的性能测试报告如下,能够达到数十万.百万级别的 QPS(暂时忽略阿里对 Redis 所做的优化),我们从 Redis 的设计和实现来分析一下 Redis 是怎么 ...

最新文章

  1. 从零实现来理解机器学习算法:书籍推荐及障碍的克服
  2. mac上nginx静态页面访问403
  3. 单片机外部中断实验C语言程序,STC89C52单片机外部中断0实验
  4. 深入浅出mybatis之入门使用
  5. android 使用shell模拟触屏_android命令行模拟输入事件(文字、按键、触摸等)
  6. 论文阅读笔记(二)——Xception
  7. 软件需求规格说明书 模板
  8. DOSBox安装及使用教程
  9. Python 爬虫对链家网广州二手房源信息的处理与可视化分析
  10. 客户关系管理理论 期末复习
  11. linux arm 携程,如何安装ARM toolchain
  12. python爬取文件内容_python爬取各类文档方法归类汇总
  13. 教你几个手机识别图片中的文字小技巧
  14. bat命令实现游戏存档自动备份
  15. java学习中,DVD管理系统纯代码(java 学习中的小记录)
  16. imprecise external abort
  17. 〖Python 数据库开发实战 - MySQL篇⑩〗- MySQL 中不同的数据类型
  18. 视通科技知识产权保护中心审理庭解决方案:助力知识产权保护中心信息化建设
  19. IIS中应用程序池和站点通过命令启停方法
  20. error loading midas.dll问题

热门文章

  1. LTE - NAS 驻网流程概括
  2. 最常用的10个mac应用,别问,问就是精品
  3. (小白)使用nslookup找不到服务器,错误:默认服务器:unknown Address: ::1解决方案
  4. Java工程师就业前景及薪资水平
  5. 芯片封装中的BSC的含义
  6. 每天10个前端小知识 【Day 13】
  7. 游戏平台在游戏运营中具有什么优势?
  8. 关于Linux中的docker-compose.yml配置文件
  9. 跨平台转码软件HandBrake, 一款万能的视频压缩/格式转换工具!
  10. mysql多实例部署