更多精彩内容,欢迎关注作者微信公众号:码工笔记
一、开源JSON库sonic-cpp
字节在2022年开源了其json解析库sonic-cpp,其性能比常用的rapidjson提升2.5倍。据其分享文章介绍,主要优化点有以下几方面:
向量化优化(SIMD)
- 序列化过程中的字符串转义
- 反序列化过程中浮点数解析
- 反序列化过程中跳过空格、换行符等字符
按需解析
- 实现按需解析时需要跳过不需要的JSON object或array,sonic-cpp使用SIMD快速匹配左、右括号数量,提升性能
- 合并JSON时也可用相似的方式优化(不需要全解析出来)
DOM结点设计优化
- JSON object和array的成员在内存中都以数组方式组织,保证内存连续性
- 为object在meta数据中保存一个<key, index>的map,此map有以下特点:
- 按需创建:只在调用接口时才生在,不是解析时创建
- 使用string_view作为key:不拷贝字符串
- 内存池:类似rapidjson定制内存分配器,避免频繁malloc,统一释放DOM树上所有结点
可以看出,其中最重要的是SIMD优化,本文重点学习一下其具体实现。
二、SIMD优化
SIMD(Single Instruction Multiple Data),指用一条指令同时操作多个数据,从而实现数据级并行。不同CPU提供的SIMD指令是不同的,x86系列CPU上是SSE或AVX指令集,ARM系列上是nenon指令集。
移动端是ARM的天下,我们主要关注neon指令集。
Neon提供了32个128-bit的向量寄存器,每个寄存器可以拆分成不同粒度的lane来存放数据(如:分成16个uint8,就可以存放16个ASCII字符),同时还提供了相应的SIMD指令来并行操作这些lane中存储的数据(这样一条SIMD指令就能同时操作16个ASCII字符)。
下面分别看一下sonic-cpp中三个SIMD优化场景的具体实现。
1、序列化过程中的字符串转义
相关代码在 include/internal/arch/common/arm_common/quote.h
文件的 Quote 方法中,其中主要代码如下:
/* VEC_LEN byte loop */
while (nb >= VEC_LEN) {
/* check for matches */
if ((mm = CopyAndGetEscapMask128(src, dst)) != 0) {
// cn = __builtin_ctz(mm);
cn = TrailingZeroes(mm) >> 2;
MOVE_N_CHARS(src, cn);
DoEscape(src, dst, nb);
} else {
/* move to next block */
MOVE_N_CHARS(src, VEC_LEN);
}
}
上述代码中,主要使用CopyAndGetEscapMask128和TrailingZeroes方法实现从src指向的字符串中获取待转义字符的位置。其中CopyAndGetEscapMask128使用了SIMD:
可以先看后面的介绍,再回来细看这些代码
static uint64_t CopyAndGetEscapMask128(const char *src, char *dst) {
//从src开始,取16条uint8大小的数据,存入向量寄存器
uint8x16_t v = vld1q_u8(reinterpret_cast<const uint8_t *>(src));
//将16条uint8的数据存入dst
vst1q_u8(reinterpret_cast<uint8_t *>(dst), v);
//将\字符拷贝16份存入向量寄存器,并与v中的16条数据进行比较(相等为0xff,不等为0x00),结果存入m1
uint8x16_t m1 = vceqq_u8(v, vdupq_n_u8('\\'));
//将"字符拷贝16份存入向量寄存器,并与v中的16条数据进行比较(相等为0xff,不等为0x00),结果存入m2
uint8x16_t m2 = vceqq_u8(v, vdupq_n_u8('"'));
//将\x20字符拷贝16份存入向量寄存器,并与v中的16条数据进行比较(小于为0xff,大于等于为0x00),结果存入m3
uint8x16_t m3 = vcltq_u8(v, vdupq_n_u8('\x20'));
//将m1与m2中存的16个uint8分别做按位或,结果存入m4
uint8x16_t m4 = vorrq_u8(m1, m2);
//将m3与m4中存的16个uint8分别做按位或,此时m5中存放的是16个uint8,每个uint8中存放的是0xff或ox00,0xff代表对应位置的字符需要escape,0x00代表不需要
uint8x16_t m5 = vorrq_u8(m3, m4);
return to_bitmask(m5);
}
uint64_t to_bitmask(uint8x16_t v) {
//1. 先将16个uint8 cast成8个uint16,相当于将相邻字符的0xff/0x00合并到一个uint16中了
//2. 将8个uint16分别右移4位并做narrowing,其效果是将比较结果中的0xff00转成0xf0,其结果是8个uint8,每4位代表一个字符是否需要escape的flag
//3. 再将8个uint8 cast 成一个uint64
//4. 最后再用vget_lane_u64将数据从向量寄存器中取出来放到通用寄存器中
return vget_lane_u64(
vreinterpret_u64_u8(vshrn_n_u16(vreinterpretq_u16_u8(v), 4)), 0);
}
//取iput_num二进制末位0的个数
int TrailingZeroes(uint64_t input_num) {
////////
// You might expect the next line to be equivalent to
// return (int)_tzcnt_u64(input_num);
// but the generated code differs and might be less efficient?
////////
return __builtin_ctzll(input_num);
}
如果之前没有接触过Neon指令,有点难看懂,咱们下面详细介绍一下。
1)SIMD指令和数据格式介绍
SIMD一般有两种使用方式,一种是使用编译器提供的instrinsics方法(如上面代码片段中的vld1q_u8、vceqq_u8等方法),还有一种是直接写汇编指令,方便起见,一般都使用intrinsics。
Intrinsics的数据类型和方法名都比较奇怪,其实是因为它包含了很多信息,其格式如下:
数据类型
数据类型主要有以下三种格式:
- 标量:baseW_t
- 向量:baseWxL_t
- 向量数组:baseWxLxN_t
其中:
- base代表基础类型,如uint
- W代表数据宽度,如8
- L代表向量的维度,如16维
- N代表向量数组的长度
W和L需要保证其乘积可以放到128bit的neon寄存器中;
N是在操作多个neon寄存器的指令中用到。
Intrinsics方法名
其格式如下:
ret v[p][q][r]name[u][n][q][x][_high][_lane | laneq][_n][_result]_type(args)
- ret 方法的返回值
- v 所有intrinsics方法的前缀,是vector的简写
- p 表明这是个成对的(pairwise)指令
- q 表明这是个saturating指令
- r 表明这是个舍入(rounding)指令
- name 指令名称
- u 有符号到无符号的saturation
- n nanrrowing指令,指返回值数据宽度比操作数窄
- q 指令名称后缀,代表此指令的操作范围是128-bit
- x 取值为b、h、s、d(分别代表8、16、32、64),表明这是个Advanced SIMD标量指令
- _high 在AArh64中,用于操作数中有128-bit操作数的widening & narrowing指令,对于widening 128-bit操作数,high说明取的是源操作数的高64位,对于narrowing指令,high说明取的是目标操作数的高64位
- _n 标量操作数作为参数
- _lane 表明是从一个向量的一个lane中取出的标量操作数,_laneq代表是从一个128位的向量中取出的标量操作数
- type 操作数类型
- args 方法参数
2)CopyAndGetEscapMask128 中的 NEON 指令
有了这些信息以后,我们再来看上面的代码片段中用到的格式。
数据类型
uint8x16_t,代表一个16维的向量,其中可存放16个8位无符号整数。
intrinsics方法
-
vld1q_u8:加载16条uint8的数据到向量寄存器中
- v:前缀
- ld1:load指令,用于读数据到寄存器中(1表示没有数据拆分,如果是VLD3,可用于将RGB值按通道拆分并分别加载到三个寄存器中)
- q:指令操作范围是128-bit
- _u8:类型是uint8
-
vst1q_u8:将16条uint8的数据存储到内存中
-
vceqq_u8:比较16条uint8的数据
-
vorrq_u8:将16条uint8的数据按位或
-
vdupq_n_u8:将uint8的数据复制16次
-
vget_lane_u64:将向量寄存器中的数据取出来放到通用寄存器中
-
vshrn_n_u16:将uint16的数据取出,右移,再存入目标寄存器的高半部分或低半部分,结果宽度为操作数宽度的一半(narrowing)
-
vreinterpretq_u16_u8、vreinterpret_u64_u8:类型转换(little endian,低位在低地址,高位在高地址)
这些方法的意义明确以后,就可以详细看具体代码流程了(详见代码注释)。
最后经过to_bitmask(m5)后,得到的uint64中存储了16个字符中每个字符是否需要escape的flag(每个字符flag占4位)。
然后取结果trailing zero个数,再除以4(因为每个flag占了4位),就能得到需要escape的字符的index。
2、反序列化过程中浮点数解析
这部分看neon实现里没有使用SIMD,avx指令集有实现,这里暂略。
3、反序列化过程中跳过空格、换行符等字符
相关代码在 include/internal/arch/neon/skip.h
文件的 GetNonSpaceBits 方法中,其中主要代码如下:
sonic_force_inline uint64_t GetNonSpaceBits(const uint8_t *data) {
//从输入数据data的pos偏移开始,读取16条uint8数据
uint8x16_t v = vld1q_u8(data);
//将''字符复制16次,并与v中的16条数据进行比较,结果存入m1
uint8x16_t m1 = vceqq_u8(v, vdupq_n_u8(' '));
uint8x16_t m2 = vceqq_u8(v, vdupq_n_u8('\t'));
uint8x16_t m3 = vceqq_u8(v, vdupq_n_u8('\n'));
uint8x16_t m4 = vceqq_u8(v, vdupq_n_u8('\r'));
//将m1与m2中存的16个uint8分别做按位或,结果存入m5
uint8x16_t m5 = vorrq_u8(m1, m2);
uint8x16_t m6 = vorrq_u8(m3, m4);
uint8x16_t m7 = vorrq_u8(m5, m6);
uint8x16_t m8 = vmvnq_u8(m7);
//得到uint64的flag
return to_bitmask(m8);
}
uint8_t skip_space(const uint8_t *data, size_t &pos,
size_t &, uint64_t &) {
// fast path for single space
if (!IsSpace(data[pos++])) return data[pos - 1];
if (!IsSpace(data[pos++])) return data[pos - 1];
// current pos is out of block
while (1) {
uint64_t nonspace = GetNonSpaceBits(data + pos);
if (nonspace) {
pos += TrailingZeroes(nonspace) >> 2;
return data[pos++];
} else {
pos += 16;
}
}
sonic_assert(false && "!should not happen");
}
主要代码都加了注释,to_bitmask和TrailingZeroes的逻辑上字符串转义基本一致。