暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

技术分享|StoneData 向量化计算(上)

石原子科技 2023-06-09
282


数据库结合现代 CPU 主要优化方向


在我们直观的认识中,处理器就是那个按着编译好的代码指令,不断顺序重复着取指、译码、执行操作的单调而可靠的机器。事实上,现代处理器对待代码指令的处理方式,早已不再是表面上看起来的那么规矩,对不同形式的代码,它将可能呈现不同的运行策略。




01

超标量

可以在每个时钟周期执行多个操作的处理器称为“超标量处理器”。现代处理器主要从两个方面实现超标量处理:

  1. 多个并行的功能单元。这些单元能同时执行相同或不同的指令,如 TI 的 C64X+ 架构就配置了8个并行功能单元,分别负责乘加、逻辑、存取等操作。VLIW(Very Long Instruction Word) 超长指令集被设计来给这多个功能单元进行指令分发;Intel IA-64 之后,目前该技术基本已经偃旗息鼓;

  2. SSE (Streaming SIMD Extensions, 流SIMD指令扩展 ),SIMD 即 Single-In-struction,Multiple Data (单指令多数据)。通过扩展额外的矢量处理功能单元以及矢量寄存器等,可以实现单个指令控制多路相同的计算,如一次做8个 Byte 的数据存取 ,又或是一次做8个16x16乘法。Intel 目前主要的 SIMD 指令集有 MMX,SSE,AVX, AVX-512,其对处理的数据位宽分别是:

  • 64位 MMX
  • 128位 SSE
  • 256位 AVX
  • 512位 AVX-512




02

高速缓存

高速缓存(cache)是一个小而快速的存储设备,一般而言,CPU 对高速缓存的访问速度仅次于寄存器。缓存空间的大小不仅与其价格高有关,更重要的是随着存储能量的扩大,存储的访问延迟将随之增加,因此很多处理器设计了多级的缓存结构,越靠近 CPU 的层级其容量越小。
现代处理器包括独立的 I-cache(指令缓存)和 D-cache(数据缓存),它们由专门的硬件逻辑来管理。简单来说,缓存管理器在 CPU 第一次访问某个低层级的存储时(缓存缺失),会连带把该存储地址之后的多个指令/数据与上级缓存交互,这样当 CPU 接下来想访问下一个连续的指令/数据时,就只需要访问缓存即可(缓存命中)。
现代化的 CPU 架构中包含 L1、L2、L3 层缓存。以 AMD Ryzen 7 5800H 为例,每层信息如下:

Level

cache size

CPU cycles

Share Level

1

L1d 256 KiB

L1i 256 KiB

3-4 cycles

逻辑核心独占

2

4 MiB

10-20 cycles

同物理核上的逻辑核心共享

3

16 MiB

40-45 cycles

所有核心共享


数据从 RAM 加载 L3 缓存,然后是 L2 ,最后是 L1 。当处理器正在寻找执行操作的数据时,它首先尝试在 L1 高速缓存中找到它。如果 CPU 能够找到它,则该条件称为缓存命中。然后它继续在 L2 中找到它,然后在 L3 中找到它。如果找不到数据,它会尝试从主存储器访问它。这称为缓存未命中。

缺失处罚是由于缓存未命中所需要的额外时间开销。对L1高速缓存来说,命中时间的数量级是几个时钟周期;L1缺失需要从L2得到服务的处罚,通常是数10个周期;从L3得到服务的处罚为50个周期;从主存得到服务的处罚为200个周期!

在 64 位操作系统中,CPU 从缓存中读取数据时按照 8 Bytes 对齐访问。如下图所示桔黄色为有效数据,数据被存放在 0x000000 ~ 0x00000F 一段连续内存中,偏移量为 0x000004,长度为 8 。
CPU 会发出两个加载指令读取 0x00000 ~ 0x000007 和 0x000008 ~ 0x00000F 两个内存单元。然后通过位移操作后将两部分数据合并。如下图所示:

所以读取未对齐的数据会有额外的读取成本和寄存器操作成本,在高性能计算时尽量将数据对齐至 8 Bytes 读取,这样才能获得最好的性能。
如下是 LevelDB crc32.cc 核心源码,为了兼容 32 位操作系统它每次处理单元是 4 bytes。代码首先按 1 byte 处理 16 bytes 对齐前缀部分数据,然后在 while 循环中一次处理 4x4 bytes 数据,最后处理掉对齐后缀部分。
    const uintptr_t pval = reinterpret_cast<uintptr_t>(p);
    const uint8_t* x = reinterpret_cast<const uint8_t*>(((pval + 3) >> 2) << 2);
    if (x <= e) {
    // Process bytes until finished or p is 4-byte aligned
    while (p != x) {
    STEP1;
    }
    // Process bytes 16 at a time
    while ((e-p) >= 16) {
    STEP4; STEP4; STEP4; STEP4;
    }
    // Process bytes 4 at a time
    while ((e-p) >= 4) {
    STEP4;
    }
    // Process the last few bytes
    while (p != e) {
    STEP1;
    }







    03

    分支预测、投机执行

    分支代码对于流水线处理而言是一个障碍,因为编译器,包括硬件非常有可能无法预知下一步到底将执行哪个分支的指令,于是只好等待分支判断结果出来后再继续填充流水线,造成流水线中的“空泡”。
    现代 CPU 为了提升指令的吞吐率,将单个指令切分成多个阶段,大致分为取指(fetch),译码(decode),执行(execute),回写(write-back),一条指令不必等上一条完全执行完成就可以开始执行了,就好比工厂中的流水线,可以大大提升指令执行的吞吐率。现代CPU实际上不止4个阶段,像 Intel 和 ARM 的处理器基本上都有十多个阶段,流水线吞吐率的优势更加明显。
    当 CPU 在处理条件分支时,CPU 的流水线仍然要准备后续的指令。如果 CPU 选择了错误的条件分支那流水线中准备的指令就失效了,CPU 执行完成分支判断后同步的准备某个分支的流水线操作,这就降低了 CPU 的执行效率。
    现代处理器采用了一种称为分支预测的技术,它会猜测是否会选择分支,同时还预测分支的目标地址。之后,使用投机执行的技术,处理器会开始取出位于它预测的分支跳转处的指令,并对指令译码,甚至在它确定分支是否预测正确之前就开始执行这些操作。直到确定了实际的分支路径,如果预测正确,处理器就会“提交”投机执行的指令的结果。
    当分支预测逻辑预测错误时,条件分支可能会招致很大的“预测错误惩罚”。这时,处理器必须丢掉所有投机执行的结果,在正确的位置重新开始取指令的过程,在产生有用的结果之前,必须重新填充指令流水线。
    在 stackoverflow 上有一个评论很高的问题,为什么搜索有序数组中大于某个值的个数比搜索无序数组更快。简要的代码如下:
      public static void countUnsortedArr() {
      int cnt = 0;
      for (int i=0; i<MAX_LENGTH; i++) {
      if (arr[i] < THRESHLOD) {
      cnt++;
      }
      }
      }


      public static void countSortedArr() {
      int cnt = 0;
      for (int i=0; i<MAX_LENGTH; i++) {
      if (arrSotred[i] < THRESHLOD) {
      cnt++;
      }
      }
      }


      搜索算法是完全一样的,只是一组是有序数组,另一组是无序数组。但是搜索时间却相差很大。
      对无序数组做分支预测是相当难的一件事情,在运行时增加了 CPU 指令流水线的成本。于是我们可以给出另外一个性能更好的算法版本,因为算法完全消除的分支预测成本。
        public static void count2() {
        int cnt = 0;
        for (int i=0; i<MAX_LENGTH; i++) {
        cnt += arr[i] < THRESHLOD ? 1 : 0;
        }
        }
        在 SIMDJson 的项目源代码中,也大量的利用位运算算法来消除分支预测。


        04

        数据预取
        对于数据密集型程序来说,我们没有办法保证某一段程序访问的内存具有很强的局部性,比如在多个数据块做标量运算。所以缓存失效问题在数据密集型程序会带来较大的时间开销,其中一部分原因是因为内存请求策略,只有 CPU 需要某个数据时才会发起内存请求,这个时候如果缓存失效,CPU 则会从主存中调取内存,这往往需要 1000+ 个 CPU 周期。
        正是如此,编译器提供了 prefetch 预取指令(比如 gcc 提供的 __builtin_prefetch 、SSE 提供的 _mm_prefetch ),prefetch 指令可以让主存提前将目标内存地址对应的数据所在的整个 cache line 从主存调入 cache 中,可以是 L1、L2 或者L3,后续的内存读取操作就大概率不会触发 cache miss 导致 CPU Stall。
        例如在 google crc32 的实现中,也利用数据预取指令来减少 CPU 空洞 的发生。
          while ((e - p) > kPrefetchHorizon) {
          prefetch next 64 bytes
          RequestPrefetch(p + kPrefetchHorizon);


          Process 64 bytes at a time.
          STEP16;
          STEP16;
          STEP16;
          STEP16;
          }







          05

          循环展开

          对于一段常规的 for 循环而言,一次循环会经历3个步骤:

            // 优化前
            for (int i=0; i<n; i++)
            Stmt(i);
            // 优化后
            if (n > 0) {
            for (int i=0; i+3<n; i+=4) {
            Stmt(i);
            Stmt(i+1);
            Stmt(i+2);
            Stmt(i+3);
            }
            switch(i % 4) {
            case 3:
            Stmt(n-3);
            case 2:
            Stmt(n-2);
            case 1:
            Stmt(n-1);
            }
            }

              Why?
              - Compiler pragmas
              https://arxiv.org/abs/1805.03374
              - Optimization heuristics
              - Loop Autotuning
              https://github.com/kavon/atJIT
              1. 执行循环体;
              2. 执行 i+=1;
              3. 分支判断是否达到退出循环条件;

              很明显可以得出,假设被次执行循环体需要 N 个 CPU Cycle,执行循环体的有效逻辑是整个 for 循环的 N/(N+2) 。当 N 越小,for 循环效率越低下。所以循环展开是增加每次循环体的执行次数,提高有效代码比例达到提升执行效率的目的。现代的编译器也会自动的做循环展开优化,上图是 LLVM 的循环自动展开优化。值得注意的是循环展开将增加代码体积,特别对于循环体较大的代码,循环展开反而会降低性能。

              下图是对于一个简单循环循环展开的收益图,我们可以看到通常做 4 次展开会获得最好的收益。



              06

              内联优化

              在介绍内联前,我们先要了解代码空洞的概念。如下图一段程序编译后,二进制可执行文件中依次包含 func funcA funcB 函数。在执行 func 函数时调用了 funcB 函数,此时需要 CPU 跳过一段内存空间加载并执行 funcB 函数。在本次执行过程中,我们称跳过的部分为 代码空洞。在执行过程中代码空洞越小,代码指令加载效率越高。

              内联是指在编译器将函数内容展开到调用方的函数体中,这是一种解决代码空洞的方法。某个方法是否内联需要充分考虑,比如某个函数在低频的分支中调用,内联反而会加剧高频分支路径的空洞。内联还会造成代码膨胀,不推荐过长的函数内联到其它函数中。



              07

              列存与向量化

              列式数据库组织磁盘或内存中给定的连续列数据。基于列的存储方式,有助于减少联机分析处理 (OLAP) 的负载,因为查询会涉及到列的一个子集,但这些列都有大量的行数。对于这类查询,使用列数据格式可以大大减少从磁盘到内存和从内存到寄存器的数据转换。这样可以有效地提高整个存储体系的吞吐量。而且,列式格式让我们可以使用一些基于每列的轻量级压缩算法。这种情况下,压缩算法性能会更好,因为压缩引擎的输入数据是同一类型数据,能够压缩的更好更快。
              向量化处理自 MonerDB-X100(Vectorwise)系统开始流行,现在已成为在现代硬件条件下构建高效分析查询引擎,进而加速数据处理的标准。这种模式需要按列表示的数据来编写高效优化的查询处理算法。向量化的过程和传统的基于元组的查询过程模式有着显著区别。两种方法最主要的不同是,前者是基于列而不是基于行 元组来重写查询处理算法。连续存储的一列数据,在内存中可以表示为一个向量,这个向量包含了该列中固定数目的一些值。
              向量模式和传统模式的第二个不同是,我们可以添加一个块,而不是在查询计划树顶部一次添加一个元组。一个块由固定的一组元组(记录)组成,它代表一组向量,这些向量和列 字段有一一对应的关系。向量块是数据的基本单元,它经由执行计划树,从一个操作符流向另一个操作符。

              传统的一次添加一个元组的处理和向量化处理比较

              在上图 中,左侧图为传统的一次操作一个元组的处理流程。扫描运算符开始获取输入数据,并通过过滤运算符开始推动元组的处理。接下来,过滤运算符传递符合条件的元组到聚合运算符。运算符不停调用查询计划树下层的下一个运算符。其结果就是位于树下层的运算符把元组推向位于树顶的运算符。这就是查询的执行过程。
              现在,由于有大量的函数调用,且每次函数调用时从一个运算符到另一个运算符需要处理或传输的数据不多,在这个执行过程中,性能开销很大。其次,当仅需要处理元组里列的一个子集时,需要传递整个元组。
              右侧的为一个向量模型,往该模型中添加一个向量块,每个向量有一组记录或列值。在这个数据集合中,有多少列,就有多少向量。不断往查询计划树上面压入一批向量,它们就是查询计划中不同操作符的输入与输出。这种方法远比其它方法有效,因为这种方法在不同的操作符间平摊了函数调用的开销,其次,操作基于列而不再基于行或元组。
              向量化的代码可以充分利用 CPU 的缓存。例如,有 10 列的一行数据和只需操作一列的查询计划。在基于行的查询处理模式中,9 列数据会不必要的占用缓存,限制了可以进入缓存的数据数量。在基于列的处理中,只会读入感兴趣的列数据,这样可以一起处理更多的值,同时有效使用了 CPU 的内存带宽。


              09

              OLAP 数据库内存对齐考虑
              在大数据分析场景中,一般需要对列做高效的筛选、分组、聚合操作。所以在业界内也通常按列存储数据、按列批量计算数据。一般存储层中内存布局需要支持如下特性:
              • 快速的顺序扫描;
              • O(1) 的随机访问;
              • SIMD 友好;
              在 Intel performance guide 中推荐以 64 bytes 对齐 (align) 和填充 (padding) 数据,选择 64 bytes 是因为可以和某些特定的 SIMD 寄存器对齐,如:AVX-512。使用 64 bytes 对齐和填充允许我们更高效的在循环中使用 SIMD 指令,而无需额外的做对齐切分操作和对齐检查操作。另外保持填充可以让编译器直接生成更好的代码。
              例如在 Arrow 中我们有一个可以为空的 int32 数组:
                [1, null, 2, 4, 8]




                其内存布局为:
                  * Length: 5, Null count: 1
                  * Validity bitmap buffer:


                  |Byte 0 (validity bitmap) | Bytes 1-63 |
                  |-------------------------|-----------------------|
                  | 00011101 | 0 (padding) |


                  * Value Buffer:


                  |Bytes 0-3 | Bytes 4-7 | Bytes 8-11 | Bytes 12-15 | Bytes 16-19 | Bytes 20-63 |
                  |------------|-------------|-------------|-------------|-------------|-------------|
                  | 1 | unspecified | 2 | 4 | 8 | unspecified |





                  以上内容是 StoneData 向量化计算的第一期技术分享,下期带大家深入了解 SIMD API 和 Java 自动向量化。

                  关于石原子科技

                  石原子科技成立于 2021 年 10 月, 拥有国内顶级的数据库人才与专家,专注于企业级实时数据仓库和 MySQL 实时 HTAP 数据库的研发与应用,依托云中立的数据技术进行产品设计,致力于为客户提供大规模、高性能、低成本的一站式实时数据分析服务。


                  石原子科技坚持精细布局、自主创新的产品研发路线,打造了三款标杆产品:


                  业内首个单机内核开源、行列混存+内存计算架构的一体化 MySQL HTAP 数据库 StoneDB:使用 MySQL 的用户,通过 StoneDB 可以实现 TP+AP 混合负载,分析性能提升 10 倍以上显著提升,不需要进行数据迁移,也无需与其他 AP 集成,弥补 MySQL 分析领域的空白,形成实时在线数据就近分析。

                  基于全场景的新一代高性能、低成本的离在线一体化实时数仓 StoneData:高度兼容 MySQL 语法,毫秒级更新,亚秒级查询,满足准实时和实时分析需求,一体化架构将实时和离线融合,减少数据冗余和移动,具有简化技术栈架构的能力;实现业务与技术解耦,支持自助式分析和敏捷分析;无论是数据湖中的非结构化或半结构化数据,还是数据库中的结构化数据,都可使用 StoneData 构建企业的数据分析平台,同时完成高吞吐离线处理和高性能在线分析,实现降本增效。

                  基于容器、IAC 等技术的一站式数据库管理服务 StoneDMP:集数据管理、结构管理、用户授权、安全审计、数据趋势、数据追踪、BI 图表、性能与优化和服务器管理于一体的数据管理服务。

                  成立至今,公司已积累了上千位用户,种子客户达 300 多家,取得 30+ 项软件著作权,成功申请并获准通过了 10+ 项技术专利,分别获评浙江省级、国家级科技型中小企业。

                  石原子科技积极参与中国数据库产业建设,目前已经成为中国信通院分布式系统稳定性实验室成员单位、中国通信标准化协会(CCSA)大数据技术标准推进委员会(TC601)全权成员单位、中国信通院科技制造开源社区成员单位、中国信通院数据库应用创新实验室成员单位、国家信创工作委员会技术活动单位、浙江省信创联盟会员单位、上海市软件行业协会团体会员、北京信创工委会会员单位,先后参与起草多项国家级和行业级标准的编写工作。产品已经通过了中国信通院分布式分析型数据库基础能力专项评测、信息安全管理体系认证,并与主流服务器、操作系统、中间件等国产化软硬件生态体系进行全面兼容。

                  文章转载自石原子科技,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

                  评论