首先,想要在文章的开头明确一个概念:查询引擎中提到的代码生成可以分为运行时代码生成和编译时代码生成。
运行时代码生成,是指将表达式、查询算子树转换成一段顺序代码在运行时执行,避免大量的虚函数调用和解释开销,通常在 Push 模型中使用。
编译时代码生成是指在编译时,将一份模版文件生成多份源代码一起打包编译,用来解决向量化引擎带来的代码爆炸问题。
本文主要针对二者中的编译时代码生成进行介绍。
1
为什么需要编译时代码生成
想要解释清楚这个问题,首先要明白什么是向量化原语,以及为什么它会导致代码爆炸的问题。
引用一段话来解释什么是向量化原语:
向量化原语是向量化执行系统中的执行单位,它最大程度限制了执行期间的自由度。原语不用关注上下文信息,也不用在运行时进行类型解析和函数调用,只需要关注传入的向量即可。它是类型特定(Type-Specific)的,即一类原语只能处理特定类型。
向量化原语的主体是Tight-Loop的代码结构。在一个循环体内部,只需要进行取值和运算即可,没有任何的分支运算和函数调用。
说实话,这段话写的挺好的,但是,只有已经懂的人能看懂 :D
看一段代码大家应该更好理解:
// 这是 IoTDB 在进行 Sum 聚合计算时的一段代码(实际有所不同)
@Override
public int addInput(Column column, IWindow curWindow) {
// 为了避免循环内的类型判断导致的大量分支预测,Column的类型判断通常放在循环外侧
switch (seriesDataType) {
case INT32:
return addIntInput(column, curWindow);
case INT64:
return addLongInput(column, curWindow);
case FLOAT:
return addFloatInput(column, curWindow);
case DOUBLE:
return addDoubleInput(column, curWindow);
...
}
}
// 在经过外侧的类型判断后,就可以保证接下来的处理过程是 type-specific 的
// 同时,这段代码没有分支判断和函数调用,是 Tight-loop 的代码结构
// 因此,这段方法就可以称为一个向量化原语
private int addIntInput(Column column) {
for (int i = 0; i < curPositionCount; i++) {
// 方法内部除了类型名外,处理逻辑完全相同
sumValue += column.getInt(i);
}
return curPositionCount;
}
这样的处理虽然保证了性能,但是这些 type-specific 的原语除了类型名称不同外,其处理逻辑完全相同,由此带来了大量的重复代码,给开发和运维都带来了很大的工作量。
上方代码可能还感受不到工作量的大小,但是如果引入了更多的数据类型和多元操作后,工作量将会爆炸式增加。比如 plus(Column a, Column b) 原语,其需要两个操作数,那么 int + long, int + float, float + double 等都需要单独编写,假如支持的数据类型有6种,那每个二元原语都有 6 * 6 = 36 种组合方式,那三元的原语呢?会有 6 * 6 * 6 = 216 种排列组合!
因此,我们考虑如果能够根据一份代码,自动生成其他所有的代码就好了。这就是基于模版的代码生成。
在经过选型后,我们决定使用 Apache FreeMarker 来作为我们的模版引擎,接下来进行介绍。
2
Apache FreeMarker
Apache FreeMarker 是一款模板引擎:即一种基于模板和要改变的数据,并用来生成输出文本(HTML网页,电子邮件,配置文件,源代码等)的通用工具。
那么怎么利用 Apache FreeMarker 生成我们想要的代码呢?我们仍然以上面提过的例子为例:
第一步,使用 FreeMarker 的模版语言 FTL(FreeMarker Template Language)
原文链接如下:https://freemarker.apache.org/docs/dgui_quickstart_template.html
写下如下模版代码:
<#list allDataTypes.types as type>
private int add${type.DataType}Input(Column column) {
for (int i = 0; i < curPositionCount; i++) {
// 方法内部除了类型名外,处理逻辑完全相同
sumValue += column.get{type.dataType}(i);
}
return curPositionCount;
}
#list>
第二步,定义一个数据模型,来告诉 FreeMarker 我们想要使用什么去替换模版中的变量。
{
"types": [
{
"dataType": "int",
"column": "IntColumn"
}
,{
"dataType": "long",
"column": "LongColumn"
}
,{
"dataType": "float",
"column": "FloatColumn"
}
,{
"dataType": "double",
"column": "DoubleColumn"
}
]
}
实际上,我们使用了 fmpp(FreeMarker-based preprocessor ),一个基于 FreeMarker 的文本处理工具,可以迭代处理文件夹内的文件,因为我们可能不止有一个模板文件要生成。
作为代价,我们需要提供一个配置文件,里面规定输入模版的来源,数据模型的来源以及输出的位置。写法可以参考 FMPP doc。
原文链接如下:
https://fmpp.sourceforge.net/qtour.html
第三步,数据模型和模版代码组合起来!
为了把数据模型和模版代码组合,在 FreeMarker 中需要编写一个 main 方法,在 fmpp 中需要一个命令行命令。但是这些我们都不想要,我们想要在 maven 编译的时候自动生成,不需要额外操心任何事情,所以我们借助了 drill 提供的 maven 插件 drill-fmpp-maven-plugin。通过这个插件,在 maven 编译时,会在 codegen 阶段根据模版生成代码,并和其他的源代码一起打包编译,生成代码就和普通源代码的处理方式一模一样。
org.apache.drill.tools
drill-fmpp-maven-plugin
${drill.freemarker.maven.plugin.version}
generate-fmpp
${codegen.phase}
generate
${project.build.directory}/codegen/config.fmpp
${project.build.directory}/codegen/templates
在 Maven 文件的配置里,我们还取代了一些原本属于 fmpp config 的内容,包括模版的输入来源和生成代码的输出位置。
至此,我们为 Apache IoTDB 成功引入了 Apache FreeMarker 作为编译时代码生成的模版引擎,解决了向量化原语带来的代码爆炸问题~
想要进一步了解代码的同学可以参考下方的 PR 链接,别忘了给 IoTDB 一个 Star 哦(狗头)
3
参考文章
Apache IoTDB GitHub
原文链接如下:
https://github.com/apache/iotdb
PolarDB-X 向量化执行引擎(1)
原文链接如下:
https://zhuanlan.zhihu.com/p/337574939
Apache FreeMarker Document
原文链接如下:
https://freemarker.apache.org/docs/dgui_quickstart_basics.html
FMPP Document
原文链接如下:
https://fmpp.sourceforge.net/qtour.html
[IOTDB-4832] Introducing FreeMarker To Auto-Generate Type-Specific Code
原文链接如下:
https://github.com/apache/iotdb/pull/7880