从 Weld 论文看执行器的优化技术

栏目: 服务器 · 编程工具 · 发布时间: 5年前

内容简介:除了 Weld 本身的贡献,论文中提到的各种用于执行阶段的优化技术也很有意思,其中的大部分都借鉴于关系型数据库或编译器。本文除了介绍 Weld 之外,也是想对这些技术做一个梳理。

从 Weld 论文看执行器的优化技术

Weld 是一个用于数据计算分析的高性能 Runtime( High-performance runtime for data analytics applications ),使用 Rust 编写,可以很容易地集成到各种大数据计算框架中,比如 Spark SQL、NumPy & Pandas、TensorFlow 等,带来大幅的性能提升。

除了 Weld 本身的贡献,论文中提到的各种用于执行阶段的优化技术也很有意思,其中的大部分都借鉴于关系型数据库或编译器。本文除了介绍 Weld 之外,也是想对这些技术做一个梳理。

本文主要内容来自于 Weld 发表在 VLDB’18 的论文

整体架构

之前说到,Weld 是一个用于数据计算的 Runtime,它的上层通常是一些计算框架,例如 Spark SQL、NumPy 等。用户用这些计算框架编写程序,这些框架将用户需要的计算翻译成 Weld 中间表示(IR),然后 Weld 对其进行一系列的优化,最后生成代码并编译运行。

做个类比,这就像 LLVM 的工作方式一样:各种语言的编译前端将高级语言翻译成 LLVM IR,LLVM 再对 IR 做一系列的优化,最后再编译成二进制。

虽然都是 IR,但实际上 Weld IR 和 LLVM IR 有很大不同:

  • Weld IR 是声明式的 :只表达计算流程,不包含具体的实现。比如下面会提到的 Builder,上层不需要指定用什么方式构建数组或是哈希表等数据结构,这些是由 Weld 优化器决定的;
  • Weld IR 是 Lazy 的 :只有当需要输出结果时,相应的 DAG 计算才会真正开始运行。

从 Weld 论文看执行器的优化技术

上图是 Weld 的整体工作过程:

  1. 上层调用 Weld 的 API 输入需要计算的 IR 程序,它会被解析成 AST;
  2. 当需要执行时,相关的函数 IR 会被拼在一起,方便进行整体优化;
  3. Weld 优化器使用一系列的启发式规则进行优化,注意结果仍然是 AST;
  4. 最后生成代码并借助 LLVM 编译成二进制。

可以看出,Weld 主要由两个部分组成:IR 和 Runtime,接下来我们依次进行介绍。

Weld IR

Weld IR 支持 int、float 等基本数据类型,以及两种容器类型: vecdict ,顾名思义,分别是数组和字典。另外还支持他们的各种组合,就像 JSON 那样。

Weld IR 的计算都通过 Builder 和 Merger 来完成,由于 Merger 和 Builder 的接口是一样的,Weld 论文中并没有把二者区分开来。下面我们统称为 Builder。

Builder 输入 输出 备注
vecbuilder[T] T vec[T] 通过 append 构建数组
dictmerger[K,V,op] (K,V) dict[K,V] 通过 put 构建字典
merger[T,op] T T 聚合计算(例如 add)
vecmerger[T, op] (idx,T) vec[T] 把 T 填在给定位置 idx 上
groupbuilder[K,V] (K,V) dict[K,vec[V]] 对数据分组 Group by K

Builder 要求实现两个方法:

  • merge(b, v) :向 Builder b 添加新的元素;
  • result(b) :拿到 b 的结果,注意之后不能再添加元素了。

下面是使用 Builder 的例子:

从 Weld 论文看执行器的优化技术

图中还有个 for ,它的语法是 for(vector, builders, (builders, index, elem) => builders) ,用来 并行地 对数据做处理——也就是往 Builder 里加元素,这是 Weld 中唯一的计算方式。

for 还可以同时处理多个 Builder,这个特性在优化的时候很有用,可以避免扫描多次数据。

Weld IR 还有些别的特性(比如方便编程的 macro),但不是本文的重点,有兴趣的同学自己看原文吧。

Weld Runtime

从 Weld 论文看执行器的优化技术

当上层输入 IR 并发出开始计算的指令时,就轮到 Weld Runtime 登场了。在代码生成之前,Weld Runtime 会对 IR 做优化,优化可以分为两种:

  1. Rule-Based Optimizer (RBO):和我们熟悉的 RBO 优化类似,是基于规则匹配的优化;
  2. Adaptive Optimizer:运行时 sample 数据,然后决定用哪种算法执行,勉强可以对应 CBO。

为什么不是 CBO?关系型数据库的 CBO 是需要以统计信息为基础的,但是 Weld 作为一个通用的 Runtime,上层框架不一定能提供统计信息(比如 NumPy)。

Weld 应用规则是依次进行的,每次运行一种优化之后(称为一个 pass)。但 pass 之间会进行剪枝,去掉无用的代码。以下我们逐条看看 Weld 做了哪些优化。

Pipeline

Pipeline 在 OLAP 系统中很常见,最经典的是 HyPer 团队提出的 consumer/produce 代码生成机制 ,可以在代码生成时尽可能生成 pipeline 的代码。

从 Weld 论文看执行器的优化技术

为什么需要 Pipeline?设想一下使用代码生成、但是不使用 Pipeline 会怎么样,那么 $R_1$ 和 $\sigma_{x=7}$ 就会分成独立的两步,$R_1$ (即 TableScan)的结果被物化到内存中,再进行 $\sigma_{x=7}$。

而 Pipeline 的代码省略了中间的物化,仅仅用了一个 if 就解决了 filter,这个代价要低得多:进行 if 表达式计算时相关数据基本还在寄存器或 Cache 里,充分利用 Data Locality,这比去内存取数据快 1~2 个数量级。

Pipeline 优化规则会在 AST 中匹配这样的模式: A的输出就是B的输入 ,对匹配到的节点应用 pipeline 优化,下面是一个简单例子:

从 Weld 论文看执行器的优化技术

Horizontal Fusion

Fusion 意为把两段代码融合成一段更精炼的代码,刚刚说的 Pipeline 也是一种 Fusion。所谓 Horizontal Fusion 是找出 被重复处理的数据 ,然后将几次处理合在一起。

例如下面图中的 IR, v0 原本被 loop over 了两次,如果把两次循环合成一次,能尽可能利用 Data Locality,减少一半的内存读取代价。

从 Weld 论文看执行器的优化技术

硬要说的话,这个规则与关系代数优化中的 Project Merge 规则最相似。论文中给了一个更好的例子来说明它的用处:像 Pandas 这类的计算框架,由于 API 设计一次只能处理一列,必须借助 Horizontal Fusion 优化。

向量化和 Adaptive 优化

向量化(Vectorization)优化也不是新鲜事,很多编译器(比如 LLVM)都能自动把循环编译成 SIMD 指令,JVM 甚至可以在运行时生成 SIMD 代码。

SIMD 全称是 单条指令、多个数据 ,即用一条指令处理多个数据计算,比如原本计算 4 个整数加法要用 4 次加法指令,用了 SIMD 之后只要 1 次。没错,就这么简单!

从 Weld 论文看执行器的优化技术

在这个 pass 中仅处理简单的、没有条件分支的 for 循环,如果满足这一条件,优化器会将被循环的数据从 T 转换成 simd[T] ,最后代码生成环节为其生成 SIMD 代码。

那对于带有条件分支的 for 循环,能否进行向量化呢?答案是, 可以,但是不一定有用。

我们先设想一下:对于有条件分支的 for 循环,它向量化之后是什么样的?SIMD 指令本身是没法处理分支的(compare 这种特别简单的除外),如果一定要用 SIMD,可以假设分支条件全都为 true 或 false,最后根据条件表达式的计算结果(true 或 false),利用 select 指令选出对应的结果。

这种方式相比普通的带分支的指令,有得有失:

  • 优势:用 SIMD 指令集可以加速计算;
  • 劣势:原本只要算一个分支,现在两个分支都要算。

注:另一个优势是,SIMD 去掉了条件跳转,不存在打断 CPU 流水线的问题。但是论文中没有提到这一点,我猜测可能是它的影响因素比较小,或是作者没有找到一个合适的代价计算方式。

论文只给出了对 expr = if(cond, merge(b, body), b) 这样单分支条件的代价建模,有兴趣的同学可以看原论文上的公式。这里只说一个粗糙的结论:当选择率(即进入 body 的概率)很小时,有分支的代码更优;当选择率比较大时,SIMD 代码更优。

我们之前说过,Weld 假设上层无法提供统计信息,因而在这一步, 由于缺乏关键的选择率信息 ,它只能采取一种 Adaptive 的思路: 同时生成有分支的代码和 SIMD 代码,运行时,首先对输入数据做个 Sample 估算一下选择率,再决定走哪个算法。

选择率(selectivity)这个概念在数据库优化器中也很常用,比如估算 Row Count 时就频繁用到了选择率估计。如果能在优化时直接拿到这个信息,想必不需要这么折腾。

Adaptive Hash Table

在 Weld 的 dictbuildergroupbuilder 中都需要构建哈希表,这里也有个 trade-off:是用 Partitioned Hash Table 还是 Global Hash Table?

  • Partitioned Hash Table 是将 build 过程分成两步,先各个线程本地做 build,最后再 merge 成完整的结果;
  • Global Hash Table 只有一张全局的哈希表,通过加锁等方式做了控制并发写入。

一般而言,如果 Group by 的基数(Cardinality)比较小,Partitioned 方式更有优势,因为并发冲突会很多;相反,如果基数很大,Global 更占优势,因为无需再做多一次 merge。

Weld 的做法很巧妙地实现了二者取折中: 各线程先写到本地的哈希表,但如果大小超过阈值,就写到全局的哈希表 。最后把本地数据再 merge 进全局哈希表。这个实现被它称为 Adaptive Hash Table。

从 Weld 论文看执行器的优化技术

Misc.

Weld 中还有还有一些优化手段,比较简单:

循环展开(Loop Unrolling)是编译器中很常见的优化,如果编译期已知 for 循环的次数很小(例如,对于一个 N*3 的矩阵,第二维度长度仅为 3),就将循环展开,避免条件跳转指令打断 CPU 流水线。

数组预分配(Preallocation)在矩阵运算中也很有用,例如,默认 vecbuiler 的实现是自动生长的动态数组。如果预先知道数组长度,就能避免数组生长的拷贝代价。

评估和总结

下面是 Weld 官网放出的性能评估,对于文中提到的这几个框架,的确做到了可观的性能提升。

从 Weld 论文看执行器的优化技术

注:这里 TensorFlow 性能是用 CPU 运行的,而非 GPU。

Weld 的最大贡献是抽象出了一个通用的执行器 Runtime。这个抽象的层级要比“代码生成”中的“代码”(比如 LLVM IR)高级(high-level)不少,但又比关系代数或是线性代数低级(low-level)的多。更可贵的是,Weld IR 仅仅包含 Builder 以及 for、if 这些最基本的语句,极其之简单。

上文提到的很多优化规则,不少来源于编译器或关系型数据库。例如 Pipeline Fusion 的思想,在编译器中其实也有体现——编译器会尽可能连续的利用寄存器、避免 store/load。但是 Weld IR 独特的抽象层级令它能做层级更高的优化,达到和数据库的 Pipeline 一样的效果。


以上所述就是小编给大家介绍的《从 Weld 论文看执行器的优化技术》,希望对大家有所帮助,如果大家有任何疑问请给我留言,小编会及时回复大家的。在此也非常感谢大家对 码农网 的支持!

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

算法笔记

算法笔记

胡凡、曾磊 / 机械工业出版社 / 2016-7 / 65

这是一本零基础就能读懂的算法书籍,读者不需要因为自己没有语言基础而畏惧。书籍的第2章便是一个C语言的入门教程,内容非常易懂,并且十分实用,阅读完这章就可以对本书需要的C语言基础有一个较好的掌握。 本书已经覆盖了大部分基础经典算法,不仅可以作为考研机试和PAT的学习教材,对其他的一些算法考试(例如CCF的CSP考试)或者考研初试的数据结构科目的学习和理解也很有帮助,甚至仅仅想学习经典算法的读者......一起来看看 《算法笔记》 这本书的介绍吧!

随机密码生成器
随机密码生成器

多种字符组合密码

XML 在线格式化
XML 在线格式化

在线 XML 格式化压缩工具