从开源JSON库sonic-cpp学习SIMD优化(NEON指令)

594 阅读9分钟

更多精彩内容,欢迎关注作者微信公众号:码工笔记

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

三、参考资料