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"
go build main.go

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

go tool 6c -I $GOROOT/src/pkg/runtime -S add.c

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,%eax
  400655:       c3                      retq
  400656:       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 // mask
    b []_ProgramEntry // 保存结构体操作结构体的的slice
}

// 字节码地址
type _ProgramEntry struct {
    vt *rt.GoType // 变量类型
    fn interface{} // 字节码地址
}
p := vt.Hash & self.m while (nb > 0 && ((uintptr_t)sp & ALIGN_MASK))
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