ICode9

精准搜索请尝试: 精确搜索
首页 > 其他分享> 文章详细

JIT Code Generation代码生成

2021-11-27 07:00:17  阅读:193  来源: 互联网

标签:代码生成 编译 Generation 代码 DataType JIT 算子 表达式


JIT Code Generation代码生成
一.表达式编译
代码生成(Code Generation)技术广泛应用于现代的数据系统中。代码生成是将用户输入的表达式、查询、存储过程等现场编译成二进制代码再执行,相比解释执行的方式,运行效率要高得多。尤其是对于计算密集型查询、或频繁重复使用的计算过程,运用代码生成技术能达到数十倍的性能提升。
代码生成
很多大数据产品都将代码生成技术作为卖点,然而事实上往往谈论的不是一件事情。比如,之前就有人提问:Spark 1.x 就已经有代码生成技术,为什么 Spark 2.0 又把代码生成吹了一番?其中的原因在于,虽然都是代码生成,但是各个产品生成代码的粒度是不同的:
最简单的,例如 Spark 1.4,使用代码生成技术加速表达式计算;
Spark 2.0 支持将同一个 Stage 的多个算子组合编译成一段二进制;
支持将自定义函数、存储过程等编译成一段二进制,如 SQL Server。

 

 

 本节主要讲上面最简单的表达式编译。通过一个简单的例子,初步了解代码生成的流程。

解析执行的缺陷
在讲代码生成前,回顾一下解释执行。以上面图中的表达式 X×5+log(10)X×5+log⁡(10) 为例,计算过程是一个深度优先搜索(DFS)的过程:
1) 调用根节点 + 的 visit() 函数:分别调用左、右子节点的 visit() 再相加;
2) 调用乘法节点 * 的 visit() 函数:分别调用左、右子节点的 visit() 再相乘;
3)调用变量节点 X 的 visit() 函数:从环境中读取 XX 的值以及类型。
(……略)最终,DFS 回到根节点,得到最终结果。
@Override public Object visitPlus(CalculatorParser.PlusContext ctx) {
Object left = visit(ctx.plusOrMinus());
Object right = visit(ctx.multOrDiv());
if (left instanceof Long && right instanceof Long) {
return (Long) left + (Long) right;
} else if (left instanceof Long && right instanceof Double) {
return (Long) left + (Double) right;
} else if (left instanceof Double && right instanceof Long) {
return (Double) left + (Long) right;
} else if (left instanceof Double && right instanceof Double) {
return (Double) left + (Double) right;
}
throw new IllegalArgumentException();
}
上述过程中有几个显而易见的性能问题:
涉及到大量的虚函数调用、即函数绑定的过程,如 visit() 函数,虚函数调用是一个非确定性的跳转指令, CPU 无法做预测分支,导致打断 CPU 流水线;
在计算前不能确定类型,各个算子的实现中会出现很多动态类型判断,例如,如果 + 左边是 DECIMAL 类型,右边是 DOUBLE,需要先把左边转换成 DOUBLE 再相加;
递归中的函数调用打断了计算过程,不仅调用本身需要额外的指令,而且函数调用传参是通过栈完成的,不能很好的利用寄存器(这一点在现代的编译器和硬件体系中已经有所缓解,但显然比不上连续的计算指令)。
代码生成基本过程
代码生成执行,顾名思义,最核心的部分是生成出需要的执行代码。
拜编译器所赐,不需要写难懂的汇编或字节码。在 native 程序中,通常用 LLVM 的中间语言(IR)作为生成代码的语言。JVM 上更简单,因为 Java 编译本身很快,利用运行在 JVM 上的轻量级编译器 janino,可以直接生成 Java 代码。
无论是 LLVM IR 还是 Java 都是静态类型的语言,在生成的代码中再去判断类型,显然不是个明智的选择。通常的做法是在编译之前就确定所有值的类型。幸运的是,表达式和 SQL 执行调度,都可以事先做类型推导。
所以,代码生成往往是个 2-pass 的过程:先做类型推导,再做真正的代码生成。第一步中,类型推导的同时,其实也是在检查表达式是否合法,很多地方也称之为验证(Validate)。
在代码生成完成后,调用编译器编译,得到了所需的函数(类),调用即可得到计算结果。如果函数包含参数,如上面例子中的 X,每次计算可以传入不同的参数,编译一次、计算多次。
以下的代码实现都可以在 GitHub 项目 fuyufjh/calculator 找到。
验证(Validate)
为了尽可能简单,例子中仅涉及两种类型:Long 和 Double

 

 这一步中,将合法的表达式 AST 转换成 Algebra Node,这是一个递归语法树的过程,下面是一个例子(由于 Plus 接收 Long/Double 的任意类型组合,没有做类型检查):

@Override public AlgebraNode visitPlus(CalculatorParser.PlusContext ctx) {
return new PlusNode(visit(ctx.plusOrMinus()), visit(ctx.multOrDiv()));
}
AlgebraNode 接口定义如下:
public interface AlgebraNode {
DataType getType(); // Validate 和 CodeGen 都会用到
String generateCode(); // CodeGen 使用
List<AlgebraNode> getInputs();
}
实现类大致与 AST 的中的节点相对应,如下图。

 

 对于加法,类型推导的过程很简单——如果两个操作数都是 Long,结果为 Long,否则为 Double。

@Override public DataType getType() {
if (dataType == null) { dataType =
inferTypeFromInputs(); } return dataType; } private DataType inferTypeFromInputs() { for (AlgebraNode
input : getInputs()) { if
(input.getType() == DataType.DOUBLE) { return
DataType.DOUBLE; } } return
DataType.LONG; }
生成代码
依旧以加法为例,利用上面实现的 getType(),可以确定输入、输出的类型,生成出强类型的代码:
@Override public String generateCode() {
if (getLeft().getType() == DataType.DOUBLE && getRight().getType() == DataType.DOUBLE) {
return "(" + getLeft().generateCode() + " + " + getRight().generateCode() + ")";
} else if (getLeft().getType() == DataType.DOUBLE && getRight().getType() == DataType.LONG) {
return "(" + getLeft().generateCode() + " + (double)" + getRight().generateCode() + ")";
} else if (getLeft().getType() == DataType.LONG && getRight().getType() == DataType.DOUBLE) {
return "((double)" + getLeft().generateCode() + " + " + getRight().generateCode() + ")";
} else if (getLeft().getType() == DataType.LONG && getRight().getType() == DataType.LONG) {
return "(" + getLeft().generateCode() + " + " + getRight().generateCode() + ")";
}
throw new IllegalStateException();
}
注意,目前代码还是以 String 形式存在的,递归调用的过程中通过字符串拼接,一步步拼成完整的表达式函数。
以表达式 a + 2*3 - 2/x + log(x+1) 为例,最终生成的代码如下:
(((double)(a + (2 * 3)) - ((double)2 / x)) + java.lang.Math.log((x + (double)1)))
其中,a、x 都是未知数,但类型是已经确定的,分别是 Long 型和 Double 型。
编译器编译
Janino 是一个流行的轻量级 Java 编译器,与常用的 javac 相比,最大的优势是:可以在 JVM 上直接调用,直接在进程内存中运行编译,速度很快。
上述代码仅仅是一个表达式、不是完整的 Java 代码,但 janino 提供了方便的 API,能直接编译表达式:
ExpressionEvaluator evaluator = new ExpressionEvaluator();
evaluator.setParameters(parameterNames, parameterTypes); // 输入参数名及类型
evaluator.setExpressionType(rootNode.getType() == DataType.DOUBLE ? double.class : long.class); // 输出类型
evaluator.cook(code); // 编译代码
实际上,也可以手工拼接出如下的类代码,交给 janino 编译,效果是完全相同的:
class MyGeneratedClass {
public double calculate(long a, double x) {
return (((double)(a + (2 * 3)) - ((double)2 / x)) + java.lang.Math.log((x + (double)1)));
}
}
最后,依次输入所有参数,即可调用刚刚编译的函数:
Object result = evaluator.evaluate(parameterValues);
References
Apache Spark - GitHub
Janino by janino-compiler
fuyufjh/calculator: A simple calculator to demonstrate code gen technology
2. 查询编译执行
代码生成(Code Generation)技术广泛应用于现代的数据系统中。代码生成是将用户输入的表达式、查询、存储过程等现场,编译成二进制代码再执行,相比解释执行的方式,运行效率要高得多。
上一节表达式编译中提到,虽然表面上都叫“代码生成”,但是实际可以分出几种粒度的实现方式,如表达式的代码生成、查询的代码生成、存储过程的代码生成等。本节要讲的是查询级别的代码生成,有时也称作算子间(intra-operator)级别,这也是主流数据系统所用的编译执行方式。
主要参考了 HyPer 团队发表在 VLDB'11 的文章 。
https://www.vldb.org/pvldb/vol4/p539-neumann.pdf
Volcano 经典执行模型
为什么要用编译执行?编译执行有哪几种实现?
主角是查询(Query)的编译执行,看看经典 Volcano 模型是怎么做的。Volcano 模型十分简单(这也是流行的主要原因):每个算子需要实现一个 next() 接口,意为返回下一个 Tuple。

 

 Query 1 是一个很简单的查询,Project 会调用 Filter 的 next() 获得数据,Filter 的 next() 又会调用 TableScan 的 next(),TableScan 读出表中的一行数据并返回。如此往复,直到数据全部处理完。

Query 2 复杂一些,包含一个 HashJoin。HashJoin 的两个子节点是不对称的,一边称为 build-side,另一边称为 probe 或 stream-side。执行时,必须等待 build-side 处理完全部数据、构建出哈希表之后,才能运行 stream-side。
因为这个原因,执行的过程分成了两个阶段(图中浅灰色的背景)。在 Volcano 模型中,这也很容易实现,试着写一下 HashJoin 的伪代码:
Row HashJoin::next() {
// Stage 1: Build Hash Table (HT)
if (HT is not built yet) // 注意:Build 仅在第一次调用 next() 时发生
while ((r = left.next()) != END)
ht.put(buildKey(r), buildValue(r))
// Stage 2: Probe tuples one by one
while (r = right.next())
if (HT contains r)
output joined row;
}
这个构建哈希表的过程,称为物化(Materialize),意味着 Tuple 不能继续往上传递,暂存到某个 buffer 里。大多数时候,如执行 Filter 等算子时,Tuple 一路传上去,称为 Pipeline。显然物化的代价是比较高的,希望尽可能多的 Pipeline 避免物化。
Query 3 中的 Aggregate 算子,有类似的情况:在 Aggregate 返回第一条结果前,要把下面所有的数据都聚合完成才行。
称 HashJoin、HashAgg 这种打断 Pipeline 的算子为 Pipeline Breaker,使得执行过程分成了不止一个阶段。分成多个阶段,因为 HashJoin 或 HashAgg 算法本身决定的,跟 Volcano 执行模型无关。
Volcano 的性能问题
Volcano 执行模型胜在简单易懂,在那个硬盘速度跟不上 CPU 的时代,性能方面不需要考虑太多。然而随着硬件的进步,IO 很多时候已经不再是瓶颈,这时候人们就开始重新审视 Volcano 模型,产生了两种改进思路:
1)将 Volcano 迭代模型和向量化模型结合,每次返回一批而不是一个 Tuple;
2)利用代码生成技术,消除迭代计算的性能损耗。
关于这两个方案哪个更优,这里有一篇非常棒的论文做了很详尽的实验和分析。
http://www.vldb.org/pvldb/vol11/p2209-kersten.pdf
就像表达式解析执行一样,Volcano 其实是对算子树的解释执行,同样存在这些问题:
每产生一条结果就要做很多次虚函数调用,消耗了大量的 CPU 时间;
过多的函数调用导致不能很好的利用寄存器。
如果让去把 Query 1 写成代码来执行,会是什么样的呢?答案非常短,短的令人惊讶:

 

 图中用不同颜色标出了原来的算子,其中 condition = true 是一个表达式,按照上一节讲解的方法就能生成出代码,放到这边 if 的条件上即可。

这两个的执行效率应该很容易看出差距!生成出的代码完全消除了虚函数调用,Tuple 几乎一直在高速缓存甚至寄存器中。论文中也提到,随便找个本科生手写代码,执行性能都能甩迭代模型几条街。
再看个更复杂的例子找找感觉,以下查询(记作 Query 4)混合了 Join、Aggragate 甚至子查询,这些算子是 Pipeline Breaker,执行过程不可避免的分成几个阶段;除此以外,希望其它部分尽可能地做到 Pipeline 执行。

 

 这个例子有点长,相信对代码生成已经有了些直觉上的理解,这对理解掌握本节的内容大有帮助。

图中用不同颜色出了 HashJoin、HashAgg 三个算子各自的代码,可以看出,各自的代码逻辑被“分散”到了不止一处地方,甚至代码中已经很难分辨出各个算子,全都融合(Fusion)到一块。
这就是想要的结果!如何自动生成出这样的代码呢?
很多人有个错觉,以为数据库查询过程那么复杂,生成的代码一定也很复杂吧。其实不然,查询中复杂的部分,如 HashJoin 中哈希表实现、TableScan 读取数据的实现等,这些不用生成很多代码,仅仅只是调用现有的函数即可,如 LLVM IR 可以调用已存在的任何函数。
换个角度看,生成的代码不过是把这些算子的实现,更高效的方式串联:算子自身逻辑就像齿轮,生成的代码好比连接齿轮的链条。
HyPer 的解决方案
代码生成是个纯粹的工程问题。工程问题没有什么不能解的,难就难在找到其中最漂亮的解。如现在这个问题,为了编程的优雅,希望造一个可扩展的框架:不论哪个算子,只要实现某种接口(就像 Volcano 模型要求实现 next() 接口一样),就能参与到代码生成中。
模型要求所有算子实现以下两个接口函数:
produce()
consume(attributes, node)
代码生成的过程总是从调用根节点的 produce() 开始;consume() 类似于一个回调函数,当下层的算子完成自己的使命之后,调用上层的 consume() 来消费刚刚产生的 tuples——注意这里并不是真的消费。
用例子来说明。下面是一个伪代码版本的若干算子实现。produce() 和 consume() 返回的类型都是生成的代码片段,这里为了方便演示直接用字符串表示。真实世界中当然要更复杂一些。

 

 表中红色的字符串是生成的代码,黑色的则是 code-gen 本身的代码。回忆一下:代码生成其实就是用各种手段拼出代码(字符串)来,没什么神秘的。

 

 不满足于伪代码,可以尝试阅读 HyPer 的 论文(生成 LLVM IR),或者 Spark SQL 中的 CodeGenerator 实现(生成 Java 代码),后者的代码相对更容易理解些。

思考:这是唯一的解法吗?

为什么是 produce/consume 呢?是否存在更简单的解呢?这里给出推导思路。
首先,如果只有一个接口函数,不妨叫 produce(),一定是不够用的。为什么这么说呢?一个函数充其量只能做出类似 DFS 的效果:每个算子只会被经过一次。这对 Query 1 还不是问题,但对于上文中复杂的 Query 4,HashJoin 的两部分代码离得那么远,用 DFS 就很难做到了。
为了处理 HashJoin,该增加一个怎样的函数呢?应该类似于一个回调,如 Query 4 中,当 DFS 进行到 ⋈a=b⋈a=b 时,希望通过一种某种方式告诉下面的 σx=7σx=7:当拿到结果后,只要用传给方法去消费这些 Tuples(生成消费这些 Tuples 的代码)。这个方法,不妨叫做 consume()。
顺理成章的,consume() 至少有个参数来传递需要消费的 tuples 有哪些列。另外,需要一个参数用来指示:调用者是左孩子还是右孩子?等价于传 this。
论文提出的 produce/consume 模式可能是唯一正确的方法,即使存在其它算法,猜想也是大同小异。
References


Efficiently Compiling Efficient Query Plans for Modern Hardware - VLDB'11
SPARK-12795 - Whole stage codegen
Everything You Always Wanted to Know About Compiled and Vectorized Queries But Were Afraid to Ask - VLDB'18
参考链接:
https://ericfu.me/code-gen-of-expression/
http://ericfu.me/code-gen-of-query/

标签:代码生成,编译,Generation,代码,DataType,JIT,算子,表达式
来源: https://www.cnblogs.com/wujianming-110117/p/15610545.html

本站声明: 1. iCode9 技术分享网(下文简称本站)提供的所有内容,仅供技术学习、探讨和分享;
2. 关于本站的所有留言、评论、转载及引用,纯属内容发起人的个人观点,与本站观点和立场无关;
3. 关于本站的所有言论和文字,纯属内容发起人的个人观点,与本站观点和立场无关;
4. 本站文章均是网友提供,不完全保证技术分享内容的完整性、准确性、时效性、风险性和版权归属;如您发现该文章侵犯了您的权益,可联系我们第一时间进行删除;
5. 本站为非盈利性的个人网站,所有内容不会用来进行牟利,也不会利用任何形式的广告来间接获益,纯粹是为了广大技术爱好者提供技术内容和技术思想的分享性交流网站。

专注分享技术,共同学习,共同进步。侵权联系[81616952@qq.com]

Copyright (C)ICode9.com, All Rights Reserved.

ICode9版权所有