• 作者:老汪软件技巧
  • 发表时间: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寄存器中;

_开源指令集risc-v_指令集开源是什么意思

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的逻辑上字符串转义基本一致。

三、参考资料