内容简介:除了 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 的 API 输入需要计算的 IR 程序,它会被解析成 AST;
- 当需要执行时,相关的函数 IR 会被拼在一起,方便进行整体优化;
- Weld 优化器使用一系列的启发式规则进行优化,注意结果仍然是 AST;
- 最后生成代码并借助 LLVM 编译成二进制。
可以看出,Weld 主要由两个部分组成:IR 和 Runtime,接下来我们依次进行介绍。
Weld IR
Weld IR 支持 int、float 等基本数据类型,以及两种容器类型: vec 和 dict ,顾名思义,分别是数组和字典。另外还支持他们的各种组合,就像 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):向 Builderb添加新的元素; -
result(b):拿到b的结果,注意之后不能再添加元素了。
下面是使用 Builder 的例子:
图中还有个 for ,它的语法是 for(vector, builders, (builders, index, elem) => builders) ,用来 并行地 对数据做处理——也就是往 Builder 里加元素,这是 Weld 中唯一的计算方式。
for 还可以同时处理多个 Builder,这个特性在优化的时候很有用,可以避免扫描多次数据。
Weld IR 还有些别的特性(比如方便编程的 macro),但不是本文的重点,有兴趣的同学自己看原文吧。
Weld Runtime
当上层输入 IR 并发出开始计算的指令时,就轮到 Weld Runtime 登场了。在代码生成之前,Weld Runtime 会对 IR 做优化,优化可以分为两种:
- Rule-Based Optimizer (RBO):和我们熟悉的 RBO 优化类似,是基于规则匹配的优化;
- Adaptive Optimizer:运行时 sample 数据,然后决定用哪种算法执行,勉强可以对应 CBO。
为什么不是 CBO?关系型数据库的 CBO 是需要以统计信息为基础的,但是 Weld 作为一个通用的 Runtime,上层框架不一定能提供统计信息(比如 NumPy)。
Weld 应用规则是依次进行的,每次运行一种优化之后(称为一个 pass)。但 pass 之间会进行剪枝,去掉无用的代码。以下我们逐条看看 Weld 做了哪些优化。
Pipeline
Pipeline 在 OLAP 系统中很常见,最经典的是 HyPer 团队提出的 consumer/produce 代码生成机制 ,可以在代码生成时尽可能生成 pipeline 的代码。
为什么需要 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 优化,下面是一个简单例子:
Horizontal Fusion
Fusion 意为把两段代码融合成一段更精炼的代码,刚刚说的 Pipeline 也是一种 Fusion。所谓 Horizontal Fusion 是找出 被重复处理的数据 ,然后将几次处理合在一起。
例如下面图中的 IR, v0 原本被 loop over 了两次,如果把两次循环合成一次,能尽可能利用 Data Locality,减少一半的内存读取代价。
硬要说的话,这个规则与关系代数优化中的 Project Merge 规则最相似。论文中给了一个更好的例子来说明它的用处:像 Pandas 这类的计算框架,由于 API 设计一次只能处理一列,必须借助 Horizontal Fusion 优化。
向量化和 Adaptive 优化
向量化(Vectorization)优化也不是新鲜事,很多编译器(比如 LLVM)都能自动把循环编译成 SIMD 指令,JVM 甚至可以在运行时生成 SIMD 代码。
SIMD 全称是 单条指令、多个数据 ,即用一条指令处理多个数据计算,比如原本计算 4 个整数加法要用 4 次加法指令,用了 SIMD 之后只要 1 次。没错,就这么简单!
在这个 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 的 dictbuilder 和 groupbuilder 中都需要构建哈希表,这里也有个 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。
Misc.
Weld 中还有还有一些优化手段,比较简单:
循环展开(Loop Unrolling)是编译器中很常见的优化,如果编译期已知 for 循环的次数很小(例如,对于一个 N*3 的矩阵,第二维度长度仅为 3),就将循环展开,避免条件跳转指令打断 CPU 流水线。
数组预分配(Preallocation)在矩阵运算中也很有用,例如,默认 vecbuiler 的实现是自动生长的动态数组。如果预先知道数组长度,就能避免数组生长的拷贝代价。
评估和总结
下面是 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 论文看执行器的优化技术》,希望对大家有所帮助,如果大家有任何疑问请给我留言,小编会及时回复大家的。在此也非常感谢大家对 码农网 的支持!
猜你喜欢:- 从 F1 Query 论文看 SQL 查询的执行模式
- 论文阅读:分布式数据流计算的执行引擎 CIEL
- ACL 2020:微软摘得最佳论文,Bengio论文获时间检验奖,大陆论文量第二
- AAAI 2019 四个杰出论文奖论文揭晓
- NAACL 2019最佳论文揭晓,谷歌BERT获最佳长论文
- ICML 最佳论文提名论文:理解词嵌入类比行为新方式
本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。
Types and Programming Languages
Benjamin C. Pierce / The MIT Press / 2002-2-1 / USD 95.00
A type system is a syntactic method for automatically checking the absence of certain erroneous behaviors by classifying program phrases according to the kinds of values they compute. The study of typ......一起来看看 《Types and Programming Languages》 这本书的介绍吧!