说到Doris,向量化技术是绕不开的话题,本文从CPU向量化原理出发,通过Cache、虚函数、SIMD等方面讨论Doris如何做的性能优化。从字面上看,向量化是指将原本逐一处理单个数值的运算,转变为同时处理多个数值集合的运算过程。我们可以通过多种视角来探讨这一概念。
从CPU的角度来看,现代处理器普遍支持将单一指令应用于多份数据的SIMD(单指令多数据)向量化计算。所谓的向量化计算,即通过使用SIMD指令集来执行操作。例如,对于拥有128位寄存器的CPU而言,它可以一次性从内存中读取四个32位的数据单元,并在同一时间完成这些数据的计算,最后将这128位的结果数据写回内存。这样的处理方式相比传统的一次性处理单一数据的方法,效率上能够提升至四倍。
上图展示了一个CPU执行乘法运算时的寄存器流程。假设内存中有四个32位整数(INT)。对于不具备SIMD支持或向量化能力的CPU来说,每个整数都需要分别从内存加载,单独进行乘法计算,之后再将每次计算的结果单独写回内存,整个过程需重复四次。然而,当CPU支持SIMD技术时,它可以一次性从内存中加载多个连续的数据,这意味着只需要一次内存加载和一次计算操作即可完成对这四个数据的同时处理。例如,可以同时对四个数据执行乘法运算,产生四个结果,这些结果会被存储在四个不同的寄存器中,随后一并写回内存。这种向量化指令的执行方式,其速度大约是传统非向量化CPU的四倍。随着CPU技术的进步,目前广泛使用的有128位的SSE指令集,后续还推出了256位的AVX指令集,以及英特尔最新推出的AVX2指令集。随着寄存器宽度的增加,可以同时处理的数据量也在增多,理论上提高了向量化操作的效率。不过,这种效率的提升并不是线性的;即寄存器位宽的增加并不意味着性能会成比例地提高。尽管如此,向量化确实使得CPU能够在一次操作中处理更多的数据,从而实现更高的处理速度。从CPU的角度出发,这就是向量化带来的性能优势。
从数据库的角度来看,向量化计算的概念与CPU领域的应用相似,主要在于将对单个值的运算转换为对一组值的批量处理。传统的数据库执行引擎采用逐行处理数据的方式,例如,在执行一次表扫描操作并应用某些过滤条件后,再进行乘法等计算时,数据库每次只能扫描出一行数据,并在这行数据上执行相应的数据判断和计算,每次操作仅针对单个值。相比之下,实现了向量化处理的数据库则不同。这类数据库将对单个记录(tuple)的操作转变为了对一组值的批量操作。具体来说,内存中的数据不再是以行的形式组织,而是以列的形式存储,所有计算均通过列式存储的数据进行连续处理。例如,在进行数据扫描并对扫描结果进行筛选时,如果需要对一组值进行判断,在传统的数据库引擎中可能需要对每个值分别执行四次判断。而在支持SIMD特性的向量化数据库引擎中,则可以一次性对一整列数据执行判断操作,而非像传统方式那样逐行判断。这种方式不仅减少了数据访问次数,还充分利用了现代CPU的并行处理能力,大大提升了查询和计算的效率。这或许是一个对你有用的开源项目,data-warehouse-learning 项目是一套基于 MySQL + Kafka + Hadoop + Hive + Dolphinscheduler + Doris + Seatunnel + Paimon + Hudi + Iceberg + Flink + Dinky + DataRT + SuperSet 实现的实时离线数仓(数据湖)系统,以大家最熟悉的电商业务为切入点,详细讲述并实现了数据产生、同步、数据建模、数仓(数据湖)建设、数据服务、BI报表展示等数据全链路处理流程。
https://gitee.com/wzylzjtn/data-warehouse-learning
https://github.com/Mrkuhuo/data-warehouse-learning
https://bigdatacircle.top/
项目演示:
实现向量化的核心任务主要涵盖三个方面。
首先,引入列式存储机制,即在Doris的执行引擎中采用基于列的内存格式。虽然Doris的存储层已经采用了列式存储,但在执行引擎层面,目前仍然沿用基于行的计算方式,即基于Tuple的处理模式,每次只能处理单个值。因此,为了实现向量化,需要将基于Tuple的计算转换为基于列的计算模式。
其次,根据新的列式存储格式,开发一套全新的向量化及列式存储计算引擎。
最后,构建一个支持列式存储和向量化处理的函数计算框架,该框架将实现所有必要的向量化操作符,包括常见的聚合、排序、JOIN等SQL操作符。通过这三个步骤,可以全面支持向量化计算,大幅提升数据处理的效率。
现有的Doris执行引擎中,内存结构如左侧图示所示,采用了一个名为RowBatch的结构来表示数据。在这个结构中,数据是通过一个个Tuple组织起来的,每个Tuple占据一块连续的内存空间。尽管RowBatch可以包含多行数据,但在内部处理时,这些数据依然是按照行的方式进行处理的。例如,图中左侧的RowBatch结构虽然显示为三列,但每行数据在内存中是连续存储的,处理时也是逐行进行。相比之下,新的内存结构被称为Block,其中的数据是以列的形式存储的。在Block中,每一列数据构成了一个连续的内存块。这意味着,现有的Doris执行引擎是基于RowBatch和Tuple实现的,而新的向量化引擎则将转变为基于Block和Column的实现方式。这种转变有助于更好地支持向量化计算,提高数据处理的效率。如图所示,现有的Block结构原本只包含两列,即a列和b列,这两列数据在内存中是以列的形式连续存储的。abs是一个计算函数,其处理流程如下:从原始的Block中读取数据,将其分为a列和b列。接着,仅对b列执行abs计算。在传统的行式存储模式下,即使a列的数据与计算无关,由于行式存储的特点,a列仍会参与到整个处理过程中,因为每行数据在内存中是连续存储的。然而,在向量化函数执行框架中,情况有所不同。a列的内存不会无谓地参与计算过程,因为它与b列在内存结构上已经分离。具体来说,当对b列应用abs函数后,会产生一个新的列——abs(b)。在此过程中,a列完全不受影响,没有发生任何内存上的交互。如右侧的图所示,完成abs计算后,会在原有Block的基础上生成一个新的连续内存区域,该区域包含新产生的abs(b)列。最后,将原始的b列移除,这样最终的Block就只剩下a列和abs(b)列,从而实现了列式存储下的向量化计算。从上图可以看出,向量化函数计算相较于传统函数执行逻辑,为何能提升性能。左侧展示的是传统函数执行的逻辑:当一个batch的数据输入时,需要逐行遍历,针对每一行数据,通过其slot来确定该行数据中的偏移量,以此获取特定列的数据。接下来,基于这一列数据,每次都需要进行类型判断,以决定调用哪个具体的函数来进行处理。例如,如果数据类型是整数(int),则调用整数的哈希函数;如果是字符串(string),则调用字符串的哈希函数。相比之下,右侧展示了列式存储下的执行逻辑,显得更为简洁高效。在处理整个Block的过程中,类型判断仅需执行一次。假设一个row batch或Block含有1024行数据,使用行式存储的执行逻辑,类型判断就需要重复1024次;而采用列式存储,只需执行一次类型判断。此外,还有一个隐含的优势:由于列式存储的数据是连续的,因此在处理连续的内存块时,可以更好地利用缓存(Cache),提高数据访问的速度和效率。- Cache亲和度高:由于列式计算的数据是按列组织的,这使得数据更容易命中缓存。例如,在上述的abs(b)计算示例中,列式计算过程中,除了目标列b外,其他不相关的列不会参与到计算中来。加之列式数据在内存中是连续存储的,进一步增强了Cache命中率,提升了数据访问效率。
- 减少虚函数调用:通过减少虚函数调用,可以降低分支跳转的频率。在向量化计算框架中,大量采用模板技术实现零成本抽象,有效减少了虚函数调用的次数,进而降低了程序执行中的分支预测错误率,提高了运行效率。
- 利用SIMD加速计算:借助SIMD技术,CPU可以从一次处理一个值转变为一次处理多个值,显著提升了计算效率和速度,使计算任务得以快速完成。
从CPU的角度分析,Cache主要分为三个部分:首先是CPU指令的Cache,其次是数据的Cache,最后是TLB(Translation Lookaside Buffer),用于将虚拟地址转换为物理地址。实际上,TLB也可以视为一种Cache,存在于CPU内部,用于加快地址转换过程。上述三个组成部分共同构成了我们通常所说的Cache体系,它们的性能直接影响到SQL查询的整体效率。如果这三部分的管理不当,可能会对SQL查询造成显著的性能影响。根据英特尔官方提供的数据,下表展示了L1、L2、L3缓存以及DDR内存访问的时间开销。从表中可以看出,L1缓存的访问时间仅为1纳秒,L2缓存为4到5纳秒,而L3缓存的访问时间大约是L2的十倍。实际上,访问主内存(DDR)的时间可能会超过100纳秒。由此可见,如果数据能够在L1缓存中找到,那么节省下来的CPU时钟周期将是非常显著的,这段时间内CPU可以执行大量的额外指令。相反,一旦需要访问DDR内存中的数据,不仅会遇到较长的访问延迟,还会涉及到内存带宽的问题。由于内存带宽是所有硬件逻辑程序共享的资源,因此还涉及到复杂的内存带宽调度问题。因此,为了提升SQL查询的执行效率,应尽可能优化程序的Cache亲和度,确保数据能够高效地被缓存和访问。- 无法进行函数内联:虚函数是动态调用的,编译器无法对其进行内联优化。因此,在实际调用时需要查找虚函数表,而这个查表过程是编译器无法内联的。函数内联的意义在于为编译器提供更多优化空间,但由于虚函数调用无法内联,编译器获取的信息较少,从而减少了代码优化的机会,这对性能有较大影响。
- 查表带来分支跳转:虚函数的执行过程中需要查表,这必然会引入分支跳转。在CPU中,分支跳转是有一定开销的。如上图所示,CPU的流水线执行流程通常分为取指、解码、执行和回写四个阶段。现代CPU的流水线更为复杂,可能包含十几个甚至几十个阶段。一旦CPU进入跳转流程,原则上流水线需要暂停。跳转指令依赖于前一个指令的执行结果来确定下一步执行的指令。虽然现代CPU会进行分支预测,让流水线继续运行,但如果分支预测成功,指令可以顺利通过流水线执行,从而获得良好的性能收益。然而,如果分支预测失败,流水线需要重新加载和执行,这会导致极大的性能开销。分支预测失败会使CPU流水线重新执行,这就是虚函数调用带来的开销之一。
SIMD技术可以分为两个方面:自动向量化和手动向量化。自动向量化主要依赖编译器在编译时的优化指令来实现。编译器会分析代码中的每个for循环,判断是否可以进行向量化。例如,在GCC中,开启O3优化级别时,编译器会自动启动自动向量化功能,但默认情况下,它会对SSE2指令集进行优化。当然,根据CPU的具体架构,你也可以配置更多的指令集进行优化。- 足够简单的for循环:循环体应该尽量简单,以便编译器能够更容易地识别和优化。
- 避免在for循环中进行函数调用和分支跳转:例如,避免在循环中使用if判断、break语句等,这些操作会增加编译器自动向量化的难度。
- 避免数据依赖:如果下一个计算结果依赖于上一个循环的结果,编译器将无法进行自动向量化。因此,应尽量避免这种情况。
- 连续的内存和对齐的内存:SIMD指令对内存的要求较高,能够一次性加载多个数据。如果数据存储在不连续的内存中,SIMD无法进行连续加载。此外,内存对齐也会影响向量化指令的执行效率。
GCC的文档中提供了一个详细的案例,有兴趣的读者可以参考。这些指导原则有助于编写更高效的代码,充分利用SIMD技术带来的性能提升。https://gcc.gnu.org/projects/tree-ssa/vectorization.html我们实现了一段代码后,如何确认编译器是否开启了自动向量化呢?编译器提供了几个提示选项,通过启用这些提示,可以在编译过程中看到编译器的相关信息。以下是具体步骤:- 打印所有编译器的向量化信息:启用这个选项后,编译器会在编译过程中输出有关向量化的信息。如果某个循环被成功向量化,编译器会打印“LOOP VECTORIZED”这样的提示。如果循环没有被向量化,编译器会打印“vec-missed”提示,但具体原因可能较难排查。
- 深入分析未向量化的原因:为了更详细地了解为什么某个循环没有被向量化,可以启用另一个提示选项。这将生成一个向量化分析树,详细说明未向量化的原因。例如,可能是向量化后的开销高于未向量化的情况,或者其他特定的技术限制。
- 使用性能工具检查生成的代码:在大型程序中,可以直接通过性能分析工具(如`perf`)或反汇编工具(如`objdump`)来查看编译器生成的汇编代码。向量化指令通常以字母`v`开头,例如`vmovaps`、`vaddps`等。通过查看这些指令,可以确认是否进行了向量化。同时,还可以观察到使用了哪些寄存器。在x86架构中,128位寄存器是XMM,256位寄存器是YMM,512位寄存器是ZMM。
通过以上方法,可以有效地确认编译器是否成功进行了自动向量化,并进一步分析未向量化的原因,从而优化代码性能。SIMD技术不仅通过硬件指令集提供支持,还通过库的形式提供了向量化API,允许开发者直接进行向量化编程。这种方式在性能上是最为高效的,但同时也带来了较高的开发成本。程序员需要熟悉SIMD的编码方式,并且必须关注底层CPU架构的细节。例如,使用编译器自动向量化时,编译器会处理这些底层细节,无需程序员过多关心。但一旦选择手写SIMD代码,就需要考虑具体的指令集支持问题。比如,实现一个abs函数的向量化算法时,如果目标机器不支持特定的abs指令集,代码将无法运行。这种方式虽然高效,但通用性和兼容性较差。例如,如果要在多个不同架构的CPU上运行,需要分别为x86、ARM和RISC-V等架构编写不同的代码。这不仅增加了开发和维护的工作量,还提高了程序员的薪资成本。在实现Doris的向量化过程中,为了规避这些问题,我们综合了自动向量化和手写SIMD的优点,选择了基于SSE(Streaming SIMD Extensions)指令集来实现所有功能。SSE是x86架构中最通用的向量化指令集,目前97%以上的服务器都支持这一指令集。通过这种方式,我们既保证了代码的高效性,又确保了其广泛的兼容性,减少了开发和维护的成本。开启向量化首先就是设置环境变量,设置一个set enable_vectorized_engine = true,如下图所示,我们可以看到node上会带一个v的结构,这个其实跟汇编指令生成的SIMD指令集是一样的,这个v就代表开启向量化。另外一个参数是我们在实际的测试当中测下来的最佳实践,set batch_size = 4096。推荐阅读系列文章