Presto代码生成

434 阅读14分钟

一、背景

1.1 场景

presto中使用了基于ASM的airlift.bytecode进行代码生成,一个主要的用途是对从数据源捞上来的数据进行表达式过滤,这是代码生成的主要应用场景,主要是为了降低进行表达式评估

中 JVM 的各种开销,如虚函数调用,分支预测,原始类型的对象装箱开销以及内存消耗。

1.2 字节码

Java编译器编译好Java文件后,产生.class文件存放在磁盘中。这种.class文件是二进制文件,内容是只有JVM虚拟机能够识别的机器码。JVM虚拟机读取字节码文件,取出二进制数据,加

载到内存中,解析.class文件内的信息,生成对应的Class对象。

class字节码文件是根据JVM虚拟机规范中规定的字节码组织规则生成的,具体的class文件可以去参考Java虚拟机规范。

    基于Java的字节码规范,我们可以实现例如程序分析、生成以及转换技术手段,可以应用在以下场景:

  1. 程序分析:从简单的语法解析到完整的语义分析,也可用来发现程序中潜在的bug,检测未使用的代码,以及反向工程等。
  2. 帮助编译器生成代码,包括传统的编译器,用在分布式编程中的内嵌的编译器,以及即时编译器等。
  3. 程序转换可以用来优化程序或者对程序进行更改,或者在应用中插入调试代码或者性能监控代码,面向切面编程等。

目前对字节码进行操作的类库有很多,ASM、Javassist、airlift.bytecode等等。由于资料不全,这里暂时只针对ASM进行简单介绍,但它们的底层原理都是相同的。Spark在闭包序列化时

也使用了ASM对闭包进行前期的清理和校验操作。

1.2.1 ASM

ASM是一个Java字节码操作框架,它能够以二进制形式修改已有类或者动态生成类。ASM可以直接产生二进制class文件,也可以在类被加载入Java虚拟机之前动态改变类行为。ASM从

类文件中读入信息后,能够改变类行为、分析类信息,甚至能够根据用户要求生成新类。

在这里我们只列出了ASM的一些核心API,通过核心API的调用,可以实现分析类型西、改变类行为、生成新类等操作,具体ASM实现原理之后将具体单独讲解。

1.2.1.1 核心类

类名类型说明依赖关系
ClassVisitorabstract类中具体信息的访问(方法、字段、内部类等等) 
ClassReader 解析编译过的class的字节数组,然后调用ClassVisitor实例的visitXXX方法,其中ClassVisitor实例作为ClassReader.accept方法的参数传递进去。ClassReader可以被看作是一个事件产生者 
ClassWriter 是ClassVisitor的一个实现,用来以二进制方式构建编译后的类。它产生一个包含编译后的类的字节数组。可以通过它的toByteArray方法来获得。它可以被看做是一个事件消费者实现ClassVisitor
ClassAdapter 也是ClassVisitor的一个实现,它将对它的方法调用委托给另一个ClassVisitor。可以被认为是一个事件过滤器

1.2.1.2 工具类

类名类型说明依赖关系
TraceClassVisitorfinal跟踪代码生成,构造解析过的类的文本展示实现ClassVisitor
CheckClassAdapter 检查类方法调用顺序,以及参数是否合理实现ClassVisitor
ASMifierClassVisitor   

1.2.1.3 接口和组件访问类

类名类型说明依赖关系
MethodVisitorabstract关于方法的生成和转换由ClassVisitor中的visitMethod方法返回
MethodAdapter 方法的转换和修改实现MethodVisitor

1.2.2 airlift.bytecode

airlift.bytecode是一个基于ASM的,用于生成Java字节码的Java类库。ASM提供了对字节码的底层操作,但当用户需要从无到有来构造一个类时,需要进行的操作较多,且复杂。

airlift.bytecode基于ASM,将Java类中的组成对象进行了抽象,提供了简易的字节码构建功能。

1.2.2.1 BytecodeNode

BytecodeNode是airlift.bytecode中的一个最底层的抽象接口,用来描述java操作的基础。BytecodeNode具有两个实现接口FlowControl和InstructionNode。

1.2.2.2 FlowControl

FlowControl接口对应Java中的流程控制语句,具有6个实现类。

FlowControl只定义了一个方法getComment,获取注释。

1.2.2.3 InstructionNode

InstructionNode指令节点有三个抽象实现类Constant、FieldInstruction、VariableInstruction以及6个直接实现类:Comment、InvokeInstruction、JumpInstruction、LabelNode、OpCode、TypeInstruction。具体功能都比较简单,这里就不再一一描述了。其中InvokeInstruction提供了对方法的调用操作。

Constant对应常量定义

FieldInstruction对应对field的操作,只有get和put两种实现类。

VariableInstruction对应对变量的改动操作,例如自增等等。

1.2.2.4 BytecodeBlock

BytecodeBlock也是BytecodeNode的一个实现类,但是它们的作用有很大区别。BytecodeBlock中存放了一个BytecodeNode的列表,并且提供了很多方法,这些方法是用来将独立的

BytecodeNode组合成一个带有执行顺序的代码块的。bytecode中的方法定义MethodDefinition中的body就是一个BytecodeBlock。

例如在Presto的CursorProcessorCompiler中,通过BytecodeBlock提供的链式操作可以构建一个全新的method的body。链式构建顺序即method的执行顺序。

1.2.2.5 MethodDefinition

MethodDefinition是airlift.bytecode对java方法的抽象,每个MethodDefinition具有一个BytecodeBlock,即它的内部执行逻辑,以及一些入参出参等成员,一个MethodDefinition必须和

一个ClassDefinition绑定。方法不能独立于类单独存在。method可以通过InvokeInstruction被触发执行。

1.2.2.6 ClassDefinition

ClassDefinition是airlift.bytecode对类的抽象,内容较少。

二、Presto应用

2.1 关键类解析

2.1.1 Compiler相关类

类型类名功能引用类生成的方法备注
配置/工具类CompilerConfig/
CompilerOperations提供了简单的逻辑操作如 and or 等函数/
CompilerUtils工具类,提供了类名生成功能和创建类的功能defineClass/
字节码body生成器(Method)BodyCompiler接口为project和filter提供method生成由于较复杂,单独抽出一个接口ExpressionCompiler(BodyCompiler是一个接口)/
CursorProcessorCompilerBodyCompiler的唯一实现类ExpressionCompilerproject_i (多个)process****filter
完整的类生成器(Class)AccumulatorCompiler生成累加器的字节码也生成一些字节码块(类中的方法)注:调用的都是generateAccumulatorFactoryBinder方法调用方都是SqlAggregationFunction的实现类AbstractMinMaxAggregationFunctionAbstractMinMaxNAggregationFunctionArbitraryAggregationFunctionChecksumAggregationFunctionCountColumnDecimalAverageAggregationDecimalSumAggregationLazyAccumulatorFactoryBinderMapAggregationFunctionMapUnionAggregationMultimapAggregationFunctionArrayAggregationFunctionHistogramAbstractMinMaxByAbstractMinMaxByNAggregationFunctiongetIntermediateTypegetFinalTypegetEstimatedSizeaddInputaddIntermediateevaluateIntermediateevaluateFinal****prepareFinalAccumulatorFactoryBinder是什么???
ExpressionCompiler调用CursorProcessorCompiler同时自己也单独定义方法LocalExecutionPlanner.visitScanFilterAndProjecttoString下面为调用CursorProcessorCompiler生成的方法project_i (多个)process****filter
InputReferenceCompiler只生成字节码body字段应用代码块被RowExpressionCompiler调用对外提供visitInputReference方法返回值为BytecodeNodeRowExpressionCompilerPageFunctionCompiler
JoinCompilergetChannelCountgetSizeInBytesappendTo isPositionNullhashPositionhashRowrowEqualsRowpositionEqualsRowIgnoreNullspositionEqualsRowpositionNotDistinctFromRowpositionEqualsPositionIgnoreNullspositionEqualsPositioncompareSortChannelPositionsisSortChannelPositionNull
JoinFilterFunctionCompilerLocalExecutionPlanner.compileJoinFilterFunctiontoString****filter
OrderingCompiler用于对比Page对象PagesIndexcompareTo
PageFunctionCompiler提供Page相关的操作ExpressionCompilerLocalExecutionPlannergetResultprocessevaluateisDeterministicgetInputChannelstoStringfilter
RowExpressionCompiler不构建类和方法,只生成ByteCodeBytecodeGeneratorContext
StateCompiler返回数组类,构建序列化类getSerializedTypedeserializeserializecreateSingleStatecreateGroupedStategetSingleStateClassgetGroupedStateClassgetEstimatedSizeensureCapacity****getEstimatedSize

2.1.2 BytecodeGenerator

BytecodeGenerator是Presto中专门用于生成Bytecode中的字节码节点的生成器。BytecodeGenerator是一个接口,只有一个方法

BytecodeGenerator有13个实现类,其中有一部分特定的Generator,在这些特定Generator之外的实现类都有FunctionCallGenerator来实现。

2.1.3 BytecodeGeneratorContext

BytecodeGeneratorContext是字节码生成器BytecodeGenerator中生成字节码的入参。

BytecodeGeneratorContext主要是各种工具类和环境变量的集合,它的成员变量如下,主要维护了一个RowExpressionCompiler生成器,和程序调用站CallSiteBinder等等。

2.1.4 RowExpression

RowExpression具有5个实现类

2.2 代码生成样例解析

下面,我们通过ScanFilterAndProjectOperator算子中对数据操作过程的代码生成样例来窥探代码生成流程。

首先,ScanFilterAndProjectOperator其中一个分支对数据的处理是通过CursorProcessor.process来完成。

2.2.1 CursorProcessor的执行

CursorProcessor是一个没有实现类的接口,它的实现类都是由airlift.bytecode动态构建生成的字节码。CursorProcessor是一个比较独立的代码生成结果,它只在

ScanFilterAndProjectOperator中被引用。我们以它为例来窥探代码生成的过程和执行过程。

在ScanFilterAndProjectOperator的getOutput方法中,若pageSource为空,则会转换到processColumnSource方法中。在processColumnSource方法中,会调用CursorProcessor的

process方法来对record进行处理。可以认为CursorProcessor是实际对数据的循环处理类,但由于CursorProcessor是一个没有实现类的接口,首先我们需要搞清楚它的构建过程。

ScanFilterAndProjectOperator的创建是由它的内部工厂类ScanFilterAndProjectOperatorFactory.createOperator创建的,CursorProcessor是工厂类的成员,传递给了创建出的

Operator实例,而ScanFilterAndProjectOperatorFactory是在LocalExecutionPlanner对物理计划节点进行遍历时产生的,ScanFilterAndProjectOperatorFactory即为物理执行计划的工厂类。

LocalExecutionPlanner中的内部类Visitor针对物理执行计划的节点类型实现了不同的visit方法,在遇到FilterNode或是ProjectNode(Presto后续可能会将这两个物理执行计划节点合并为

一个节点)时会调用visitScanFilterAndProject方法。

visitScanFilterAndProject方法的整体处理流程如下:

  1. 获取节点的输入类型和输出类型。其中在获取输入类型是,需要判断该节点的下级节点sourceNode,若sourceNode类型为TableScanNode,则直接从TableScanNode的输出Symbol集合中获取本节点的输入类型,否则直接从sourceNode的layout信息中获取。输出类型则不区分sourceNode的类型,统一从节点自身的outputSymbol中获取
  2. 由于compiler的入参不是Symbol而是Optional,我们需要先进行格式转换,以满足compiler的参数格式。主要是将输出Symbol转换为ProjectExpression,结合已有的FilterExpression传递给compiler。
  3. 若下级节点sourceNode类型为TableScan,且scan后的column不为空,则会同时编译生成CursorProcessor和PageProcessor,用这两个Processor来构建一个ScanFilterAndProjectOperatorFactory并封装到PhysicalOperation中返回。否则只会生成PageProcessor,并构建一个FilterAndProjectOperatorFactory封装到PhysicalOperation中返回。

在这个节点的visit函数中,Processor的构建都是在ExpressionCompiler中完成的,ExpressionCompiler提供了两个入口方法compileCursorProcessor和compilePageProcessor。

2.2.2 ExpressionCompiler编译生成CursorProcessor类

从2.2.1章节中我们了解到CursorProcessor在ScanFilterAndProjectOperator物理算子中对数据进行真正的执行,且它的初始化过程是在LocalExecutionPlanner.Visitor内部类中的

visitScanFilterAndProject方法中进行编译生成的,且编译时的入参是filter和project的Expression。实际编译动作在ExpressionCompiler类中的compileCursorProcessor和compilePageProcessor方法中进行。本章我们主要针对compileCursorProcessor方法进行解析。

首先我们来看一下ExpressionCompiler的成员变量和构造函数。ExpressionCompiler拥有一个LoadingCache<CacheKey, Class<? extends CursorProcessor>>的成员变量,且在构造

函数中定义了这个LoadingCache的CacheLoader。

即ExpressionCompiler在内存中对CursorProcessor进行缓存,且当有调用者试图从缓存中获取一个CacheKey对应的CursorProcessor,它会先检查是否存在,若不存在,则使用

CacheLoader中定义的Supplier根据传入的CacheKey进行初始化。且初始化的时候针对CacheKey中的内容调用了它自身的compile方法。

上文提到的,实际编译方法compileCursorProcessor中其实就调用了这个LoadingCache中的getUnchecked(即当CacheLoader没有处理抛出异常时的获取缓存数据的方法)

也就是说,当LocalExecutionPlanner试图调用ExpressionCompiler的compileCursorProcessor方法来编译一个新的CursorProcessor时,它实际调用了ExpressionCompiler的compile方

法,根据compile方法的实际调用链,CursorProcessor的构建方法实际是在compileProcessor方法中完成的。

compileProcessor的入参为已经经过类型转换的过滤表达式filter,以及投影表达式projections,一个用来构建类中方法的服务类BodyCompiler,以及一个在LoadingCache中写死的父类

CursorProcessor。注意,这里也就说明了为什么CursorProcessor在源码中是一个没有实现类的接口,但是在实际数据调用是却调用了这个接口中的方法。因为这个接口的实现类是根据查询语句动态构建出来的。

compileProcessor的构建流程在它自身中看起来比较简单,首先,它会调用airlift.bytecode中的ClassDefinition来创建一个新的类,类名使用makeClassName方法生成了一个带有时间戳

后缀的CursorPorcessor类,并定义了它的父类Object和CursorProcessor。其次,compileProcessor会调用BodyCompiler来生成这个类中的具体字节码内容,主要是类的各种方法,由于这里的方法构建逻辑较为复杂,直接抽出了一个独立的服务类BodyCompiler。BodyCompiler是一个接口,且只有一个唯一的实现类CursorProcessorCompiler。(猜测Presto是期望把所有字节码body都用BodyCompiler的实现类来实现,但实际开发中并没有达成???可能是其他类的方法比较简单???)。最后,生成了一个toString的方法,便于调试。从compileProcessor的处理流程我们可以发现,主要的代码生成集中在类中的method的生成。即CursorProcessorCompiler.generateMethods。

2.2.3 CursorProcessorCompiler编译生成CursorProcessor类中的方法

CursorProcessorCompiler专门负责为动态变异的CursorProcessor类来生成字节码body,即方法。CursorProcessorCompiler对外只提供了generateMethods方法,为了实现具体的方法,又新建了几个private 方法:

  1. generateProcessMethod:生成"process"方法,用来处理数据
  2. createProjectIfStatement:生成project方法中的if语句
  3. generateMethodsForLambdaAndTry:生成lambda表达式方法
  4. generateFilterMethod:生成"filter"方法
  5. generateProjectMethod:生成一系列"project"方法
  6. fieldReferenceCompiler

它的整体执行过程如下:

  1. 调用generateProcessMethod方法,生成"process"方法,用来处理数据
  2. 生成有filter前缀的过滤lambda方法
  3. 根据lamdba方法生成filter方法
  4. 遍历project表达式,生成多个project前缀方法,后缀为计数
  5. 声明构造函数

下面,我们针对每个步骤进行详细的解析

2.2.3.1 generateProcessMethod

generateProcessMethod方法的入参比较简单,只包含原始的类型一ClassDefinition和project表达式的数量,不涉及具体的表达式内容。

generateProcessMethod的步骤主要分为以下几个步骤

  1. 声明参数类型,ConnectorSession、DriverYieldSignal、RecordCursor、PageBuilder

  2. 声明方法,使用上面的参数类型,声明方法名为method,限定符为Public,返回类型为CursorProcessorOutput

  3. 在方法作用于中声明局部变量completedPositions: int和finished: boolean

  4. 变量初始化,调用MethodDefinition.putVariable方法,将completedPositions初始化为0,finished初始化为false

  5. 构建方法中的循环体WhileLoop

    1. 构建第一个if语句if (pageBuilder.isFull() || yieldSignal.isSet()) return new CursorProcessorOutput(completedPositions, false);
    2. 构建第二个if语句if (!cursor.advanceNextPosition()) return new CursorProcessorOutput(completedPositions, true);
    3. 构建不满足前面两个if条件下的执行操作,即执行projection,调用CursorProcessorCompiler.createProjectIfStatement
  6. 执行完ProjectIfStatement后,completedPositionsVariable加1

  7. 组装method

其中,createProjectIfStatement方法中调用了还未声明,但接下来即将声明的方法filter、project_x。虽然createProjectIfStatement看起来是一个条件执行语句if,但是实际上if的

condition都为空或者恒等于true,也就是这个方法等于实际上的顺序调用。

  1. 直接调用filter方法
  2. 获取block位置
  3. 调用project方法

即,process为数据的实际执行过程,实际执行时是先对整体数据进行filter,再依次进行投影。

2.2.3.2 generateMethodsForLambdaAndTry

在定义好process方法后,调用generateMethodsForLambdaAndTry将filter中的lambda表达式提取出来,构建为一个PreGeneratedExpressions。

过程略

2.2.3.3 generateFilterMethod

generateFilterMethod方法生成了"filter"方法,它主要是依赖于RowExpressionCompiler来生成作用于行的表达式,包括and,or以及上一步生成的lambda表达式。

RowExpressionCompiler接收将cursor包装为filedReferenceCompiler作为参数,对Expression中的每个节点进行遍历,最终返回一个BytecodeNode作为方法的实际内容。

2.2.3.4 generateProjectMethod

和filter的处理方式一致,只不过filter是一个整体expression,但每个column上的函数可能不一致,例如有些列可能在做投影时加上coalesce函数,因此project需要根据column个数生成多个方法并在process方法中循环调用。

2.2.3.5 declareConstructor

构造函数中没有特殊的逻辑,只是将它父类的构造函数传递进来了。因为CursorProcessor和Object是当前构造类的父类。

三、总结

airlift.bytecode对ASM的封装比较完整,整体操作较简单。Presto的代码生成中复杂的还是Presto内部定义的一些专用对象,理解Presto的代码生成,必须先将Presto内部的一些对象功

能理解清楚才能正确理解到Presto每一步操作的用意,例如RecordCursor、PageBuilder、BlockBuilder等等。

\