- 作者:老汪软件技巧
- 发表时间:2024-10-02 15:01
- 浏览量:
一、开源JSON库sonic-cpp
字节在2022年开源了其json解析库sonic-cpp,其性能比常用的rapidjson提升2.5倍。据其分享文章介绍,主要优化点有以下几方面:
向量化优化(SIMD)
按需解析
DOM结点设计优化
内存池:类似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的数据类型和方法名都比较奇怪,其实是因为它包含了很多信息,其格式如下:
数据类型
数据类型主要有以下三种格式:
其中:
W和L需要保证其乘积可以放到128bit的neon寄存器中;
N是在操作多个neon寄存器的指令中用到。
Intrinsics方法名
其格式如下:
ret v[p][q][r]name[u][n][q][x][_high][_lane | laneq][_n][_result]_type(args)
2)CopyAndGetEscapMask128 中的 NEON 指令
有了这些信息以后,我们再来看上面的代码片段中用到的格式。
数据类型
uint8x16_t,代表一个16维的向量,其中可存放16个8位无符号整数。
intrinsics方法
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的逻辑上字符串转义基本一致。
三、参考资料