欢迎光临散文网 会员登陆 & 注册

语涵编译器面向程序员的介绍——管线,基础IR,以及默认管线中的各个抽象

2023-08-09 20:06 作者:远行的泥土  | 我要投稿

1. 简介

本文从讲解设计理念和源代码的角度介绍语涵编译器。语涵编译器的本体是一个 Python 程序(Python >= 3.10)。本文需要读者:

  • 熟悉计算机编程,用过 Python 编程语言,最好还能了解 C/C++ 编程语言

    • 如果能了解 Python 的类型标注(Type annotation)那就更好了,源代码里到处都是。。不过没见过也没关系。

  • 已经看过语涵编译器的宣传片项目介绍文章

  • 对视觉小说引擎的功能有所了解

  • (可选)了解 RenPy 引擎的语法

  • (可选)对 Clang/LLVM 编译器架构有所了解

阅读完本文后,读者应该可以:

  • 能够独立阅读语涵编译器代码库

  • 掌握编写语涵编译器插件的能力

源代码在 https://github.com/PrepPipe/preppipe-python, 以下所有的样例都以 6c99407512caf6731a570434a2aecadcfd8ac10b 提交(版本号 0.0.1.post119)为基准。下面所有的 Python 脚本的位置在代码仓库里都有 src/preppipe/ 前缀(除非特殊说明),比如下面的 irdataop.py 就是在 src/preppipe/irdataop.py, 在 import 时可以写 "import preppipe.irdataop" 或者其他变体来使用里面定义的内容。

这里第 1 节是对本文的大体介绍,下面的第 2 节讲一些可以帮助理解的背景知识。第 3 节介绍语涵编译器的管线,大致介绍语涵编译器的各个模块是如何组合在一起的。第 4 节介绍语涵编译器的基础 IR ,这是后面所有数据结构的支撑。第 5 节介绍语涵编译器的各个数据 IR 以及它们如何使得语涵编译器能够处理视觉小说剧本。

本来想做视频的,请原谅笔者偷懒。。。文本实在是太长了。。。

2. 背景

2.1 Python 修饰符(Decorator)

Python 的 Decorator (修饰符)是一个方便在函数、类(class)刚被定义时对其进行处理的语言功能,语涵编译器所有的“注册”功能和对部分类的拓展(比如给它们加成员函数)都是通过修饰符实现的。

代码1:(把类型标注去掉后的)irdataop.py 中的修饰符定义

以 IROperationDataclass / IROperationDataclassWithValue 为例,这里我们有个函数叫 _process_class(cls, ...), 这个参数对被传入的类 cls 加一些成员(后面会细讲)并在完成后返回传入的cls,我们想要在“要被改造的类”刚刚定义时就调用这个函数来修改该类。这时候我们就可以用上面“代码1”中的例子来定义两个修饰符,没有参数的话用下面的 IROperationDataclass, 有参数的话用上面的 IROperationDataclassWithValue,前面加 '@' 之后放在类的定义前,比如下面的代码2和代码3:

代码2:renpy/ast.py 中使用不带参数的修饰符(IROperationDataclass)的例子

代码3:vnmodel.py 中使用带参数的修饰符(IROperationDataclassWithValue)的例子

有了这些修饰符之后,当 Python 解释器读取完所标注的类之后, _process_class() 会立即被调用。如果像代码3中那样有多个修饰符,那么最下面的、最靠近类型定义的先被执行,比如这里的 IRObjectJsonTypeName 会先执行,IROperationDataclassWithValue 会在之后执行。


2.2 Clang/LLVM 编译器管线

LLVM 是一个开源的编译器框架,其他人可以使用 LLVM 开发不同语言的编译器。Clang 是一个基于 LLVM 的 C/C++ 编译器。语涵编译器的管线设计大量参考了 Clang/LLVM 的设计。

在 Clang/LLVM 的管线里,如果要编译一个 C 程序到 x86-64 (正常人的PC平台),流水线基本上是这样的:(我们以做手办为比方,可能不太恰当,因为笔者没有做过手办。。)

  • Clang 使用C预处理器将预处理器命令处理完毕,生成完整的编译单元(Compilation unit)的源代码。编译单元=C/C++源文件+所有#include 的头文件。预处理结束后,编译单元内(1)包含了所有需要编译器编译的内容(包括头文件里的),(2)所有不需要编译器编译的内容都会被去掉(比如用 #if...#endif 包裹的、不启用的内容)。之后所有的编译都以编译单元为整体。打个比方的话,这是甲方(程序员)眼中的“手办该是什么样子”的需求说明。

  • Clang 将其解析为 AST(语法树),这是编译器视角的“这个编译单元里有什么”,并且已经转换为一个树状的结构,方便代码处理。打个比方,这是乙方(编译器)眼中的手办什么样的说明。

  • Clang 使用代码生成(CodeGen) 将AST生成为 LLVM IR (Intermediate Representation, 一般简称 IR)。这个 IR 是一个抽象(不局限于 x86-64)的程序实现,打比方就像是一个手办的3D建模。各种手办制作方法可以用这个建模来做手办,但大概率还不能直接用。

  • LLVM 对 IR 进行分析、优化,结果还是以 LLVM IR 的形式保存。打比方就像是调整、细化建模。

  • LLVM 使用 x86-64 后端进行代码生成,将 LLVM IR 生成为 x86-64 的 MIR (Machine IR,机器相关的 IR)(代码9),打个比方就像是适用于某个3D打印机的手办设计文件。

  • LLVM 继续代码生成,写出 x86-64 汇编或可执行代码(代码10)。到这才算把手办做出来了。

这样的流水线可以实现:

  1. 便于拓展,新的功能(主要是编译器优化)可以用这些中间形态作输入输出的接口,与其余的代码无缝对接。新的后端也可以复用已有的前端和优化代码。打个比方,换个新的手办制作厂家的话,不用从需求文档开始重新做,只要重新做建模之后的步骤就行了。

  2. 便于调试,每个阶段都可以输出为文本信息,方便排查问题。打个比方,如果手办有问题,到成品阶段再发现、改正问题就会消耗不必要的人力物力。这种情况下还可能不知道问题出在哪,从头开始详细追踪的话更加费时费力。如果在手办制作中的各个阶段及时找到问题的话,就可以及时找到问题,减少不必要的麻烦。

附录1中有一个通过一个 C 程序 Hello World 的样例来介绍各个阶段的样例,有兴趣的读者可以阅读。注:该样例并不需要读者理解,没有C背景的读者完全可以跳过。

2.3 MLIR

MLIR 是将 LLVM IR 的设计思路拓展、使其能够(1)服务于编译器管线中更多阶段(比如AST阶段),(2)更好地服务于像GPU、专用硬件这类加速器的编译工作。最主要的是,MLIR将IR的“形”(存储、表示方法)给固定下来、使其通用,各个“IR”(包括以前不被认为是IR的AST等中间形态)的“神”(即内容的含义)还是交给各个阶段自行定义。这样更加方便拓展新功能,不同的编译器管线阶段能够复用的代码更多。

语涵编译器的使用的基础 IR 是基于 MLIR 的魔改版,下文会详细介绍魔改后的基础 IR。

3. 语涵编译器的管线

3.1 管线总览

为了能让读者了解管线的整体情况,我们从图形界面的调用方式开始:


图1:语涵编译器启动器(图形界面)准备好选项之后的截图

在输入参数准备好之后,执行区下方画红框是所有要传给主程序的参数。这里的 preppipe_cli<版本号>.exe 是用 Pyinstaller 打包后生成的可执行文件,用来打包的入口是这个:

代码4:打包的入口脚本 preppipe_cli.py (在代码仓库根目录下,不属于 preppipe package)

注:虽然主函数在 preppipe.pipeline.pipeline_main(),但是这里必须有 import preppipe.pipeline_cmd,如果用 python -m 调用的话也请使用 python -m preppipe.pipeline_cmd,原因稍后解释。

这里的主函数调用了 _PipelineManager.pipeline_main(),这个函数基本做了以下的事:

  1. 读取插件(从环境变量 PREPPIPE_PLUGINS 所指向的目录里读取,如果这个环境变量有的话)。所有的插件都会接在 "preppipe.plugin.*" 模块路径下。

  2. 创建命令行解析器 (Python 的 argparse.ArgumentParser)

  3. 创建一个 Context 对象(4.1里会细讲,一个给其余代码提供支持的类)并对其初始化。

  4. 使用命令行把要执行的步骤都列出来(即把管线搭建出来)。这一步同时会对命令行输入做一个基本的检查。

  5. 执行管线的各个步骤。

比如图1样例中的命令:

执行时我们可以看到以下输出:

这些就是上面第4步的输出,列举了搭建出来的管线的细节。后面的输出就是第5步(执行)的内容了。

在讲其他内容之前,我们还需了解一下语涵编译器的基础 IR。语涵编译器给管线里流动的所有数据结构使用统一的表示法(类似JSON 那样的泛用的数据结构),在这种表示法下,数据也是像 JSON 那样有嵌套结构,JSON是【对象和数组】套【对象和数组】,基础 IR 使用的表示法是"操作项"(Operation)套""(Region),区套“”(Block),块套操作项。JSON顶层可以是数组可以是对象,基础 IR 的顶层一定是操作项。使用基础 IR 时,不同的数据会创建新的类并继承操作项(Operation),所以在语涵编译器管线里的数据基本都是 Operation 的子类。当我们描述管线里的数据类型时,我们使用最外层(没嵌套的)Operation 的子类来指代该类型。关于操作项的细节会在下面的 4.3 节叙述。(语涵编译器使用的基础 IR 是从 MLIR 魔改而来的。)

接下来我们讲 TransformBase 类,这是构成管线的基础。3.2 节大致描述 TransformBase 类和它在管线中的作用,3.3 节详细讲述代码。

3.2 TransformBase 类初探

语涵编译器中所有对数据的处理都使用 TransformBase 基类(Base class)的接口接入管线。一个 Transform (转换、变换) 有点像数学概念上的传递函数(Transfer function),或者像是其他编译器中的 Pass (不过编译器中的 Pass 一般只指代输入输出都是IR 的步骤)。语涵编译器使用这个泛化的“转换”概念将输入、输出、(狭义的)转换的接口都统一了。比如 3.1 样例中,第一行那里 ReadOpenDocument (那种格式的名字就叫"OpenDocument") 是 TransformBase 的子类,后面所有管线步骤后面的除了输入的选项(flag)外,后面的就是 TransformBase 子类的名字。

转换有三类:(我们使用 IR 来指代在管线里流动的数据)

  1. 前端转换(Frontend): 该转换不读取当前管线里的 IR,执行结束后会生成 IR。上面例子中的步骤1(读取 odt 文件)就是前端转换。这类转换可以读取外部文件也可以不读取;凭空生成 IR 常用于调试。

  2. 中端转换(MiddleEnd):该转换读取当前管线里的 IR,同时也会生成 IR。上面例子中的步骤2-8 都属于中端转换。这些可以说是传统编译器概念上的 Pass。

  3. 后端转换(Backend): 该转换不覆盖当前管线里的 IR。上面例子中的步骤 9 属于后端转换。这类转换可以导出文件也可以不导出;不导出的常用于调试。

我们用下面这张图为例(一个假想的管线)说明管线支持的转换间的组合方式:


图2:管线支持的转换组合和输入输出方式

图2中所有的方框(A-G)都是转换步骤(TransformBase 子类实例),所有的圆形连接(1-5)都是 IR 类型(Operation 子类),所有的文件图标(没标号)代表输入输出的文件。左边是红色的转换和文件是输入部分,绿色的转换步骤是中端转换,绿色的圆形连接是在管线内流动的 IR,右边蓝色的转换步骤是后端转换。该样例管线是按A-G的顺序执行的。对照以上图示:

  1. 前端转换(Frontend)不读取当前管线里的IR,除此之外无关乎执行顺序,它们可以在任意阶段出现。前端转换C在前端转换B之后执行,它生成的IR (3号)会在下一个转换开始执行前与转换B之后的转换组合在一起。管线要求这里 2号 IR 和 3 号 IR 的类型一致,否则会在构建管线时报错。

  2. 后端转换(Backend)不覆盖当前管线里的IR,除此之外无关乎执行顺序。这里的后端转换E在中端转换F前执行,其结果不影响后面执行的 F 和 G。语涵编译器里的“生成分析报告”、调试信息等都是以后端转换 E 这种形式实现的。

  3. 除了管线会帮助前端获取输入参数、帮助后端获取输出参数之外,管线并不管转换是否会在执行过程中读取或输出其他文件。转换可以再新加命令行参数来辅助输入输出。图上的黑色虚线箭头和文件标志代表这种额外做的输入输出。注意:如果要读取的内容是通过输入的数据指定的,请考虑使用 FileAccessAuditor (4.2节)。

3.3 TransformBase 的实现和命令读取

TransformBase 基类长这样:

代码5:TransformBase 基类 (pipeline.py)

install_arguments() 和 handle_arguments() 我们稍后再讲。ctx 是管线里使用的 Context 对象的引用,下面的 4.1 节详细描述。_inputs 和 _output 是输入和输出参数的地方,一般使用不加前缀下划线的名称来使用它们。这里的 run() 成员函数是一定要子类提供的,转换在执行时就调用这个函数。最简单的转换可能只需要提供一个 run():

代码6:"--vn-entryinference" 调用的转换的类型定义。(transform/vnmodel/vnentryinference.py)

这段代码里的 VNEntryInferencePass 就是 TransformBase 的子类,用来将同文件内 vn_entry_inference() 提供的功能接入管线。这个函数输入、输出的顶层操作项(Operation)都是 VNModel (对标 LLVM IR 的抽象的演出内容, 5.2 节会细讲),所以 self.inputs 里面有个 VNModel,输出时要在 run() 结束时返回一个 VNModel。由于该函数是在输入上原地进行转换(换句话说就是不新建一个 VNModel),返回时就直接 "return self.inputs[0]"。

定义转换的子类之后需要使用修饰符注册这个转换。目前一共有三个修饰符,都在 pipeline.py 中定义:

代码7:用于注册转换的修饰符 (pipeline.py)

其中 @MiddleEndDecl 用于注册中端转换,所以代码6中的中端转换使用这个修饰符进行注册。三个修饰符都有 flag (命令行上使用的选项), input_decl (输入应该是什么),output_decl (输出应该是什么)三个参数,pipeline.py 中使用这些信息来在命令行启用这些转换。对于中端转换来说,输入输出都是 IR 类型,所以 input_decl 和 output_decl 都使用类型对象(比如上面代码 6 中的VNModel)作为参数。如果是前端或后端转换,输入输出项使用一个 IODecl 的实例进行描述,比如:

代码8:读取 ODT 文档的转换的注册部分 (frontend/opendocument.py)

代码8是读取 odt 文档的输入转换的注册部分。生成的 IR 的顶层是 IMDocumentOp,5.1节会讲。

如果除了输入、输出路径之外,转换有其他参数需要指定,那么该转换需要使用另一个修饰符 @TransformArgumentGroup 来创建一个命令组(Python 的 argparse._ArgumentGroup)

代码9:RenPy 最后导出工程目录的后端转换 (renpy/passes.py)

@TransformArgumentGroup 必须加在前/中/后端声明的外面(要比它们晚执行)。两个参数,一个是命令组的标题,一个是描述,在这之后就可以通过覆盖 TransformBase 里的 install_arguments() 和 handle_arguments() (见代码5)来注册、读取额外的命令行参数。

现在我们可以解释为什么 pipeline.py 定义了 pipeline_main() 但是 python -m 一定要从 pipeline_cmd.py 启动了。pipeline.py 定义了这里所有的基础类型,所以所有要注册转换(TransformBase)的模块都会引用(import) pipeline.py, 这也使得 pipeline.py 不能引用这些模块。我们在执行时,一定需要 Python 解释器读取过所有这些有转换的模块,这样才能执行注册这些转换的代码,使它们能够成功执行。 pipeline_cmd.py 就是这样一个 import 了语涵编译器代码仓库里所有带有转换的模块的这么个“枢纽”,import 了这个模块(或者从这个模块开始执行)的话就能保证代码仓库里的转换都已经注册。

3.4 一个简单的插件

阅读完以上内容后,您应该能够写一个什么都不做的插件了。这里我们给出一个例子:

代码9:测试用的插件 (test.py,置于一个独立的目录中)

图3:在语涵编译器中启用测试插件所需要的改动

图3显示了要在启动器启用代码9中的测试插件需要做的设置改动。首先,红框部分,要把 test.py 放在一个目录中(该目录不应该有其他无关的 Python 源代码,否则都会被作为插件读取)。其次,在蓝框部分,需要把启用测试转换的命令行"--donothing"加上。这是我们可以看到在执行部分,"--donothing" 被加到了 "--vn-longsaysplitting" 之后、"--renpy-codegen" 之前,这里就是这个测试用的转换会被执行的地方。点击执行后,可以看到如下输出:(这是笔者电脑上的结果)

这样我们的什么都不做的插件就执行好了。当然,什么都不做的插件没什么用,如果我们想实际上手修改数据,那么我们需要开始了解语涵编译器的基础 IR (第 4 节),然后了解基于基础 IR 的、实际描述数据的操作项子类 (第 5 节)

4. 语涵编译器的基础 IR

语涵编译器的基础 IR 是从 MLIR 的设计魔改而来,它与 Context 对象一起给语涵编译器里所有跨模块(跨转换)的数据提供了便于调试的数据底座。就像之前描述的那样,类似 JSON 那种对象和数组互相嵌套的结构,基础 IR 的大致形态是“操作项”(Operation)套“区”(Region),区套“块”(Block),块再套操作项,数据将自定义操作项的子类并使用基础 IR 提供的区、块类型。这一节我们先从 Context 对象开始讲起,后面再讲基础 IR 的其他细节。

除非特殊说明,否则这一节提到的所有类都在 irbase.py 中定义。

4.1 Context 对象

语涵编译器中的 Context 类/对象参考了 LLVM 中 LLVMContext 类的设计。语涵编译器每次运行只应有一个 Context 对象,该对象不应被继承。Context 对象提供了以下功能:

  1. 常量池:所有的常量、字面值(Literal)(包括字符串、文本、图片、音频素材等)、类型对象、位置元数据等都由 Context 管理并去重(去除重复,Uniquing)。当任意 IR 需要记录一个图片、音频素材时,会在 Context 中保存该素材,并在 IR 内容中生成一个对该素材的引用。这样,后续步骤中要将素材传递给另一个 IR 类型时,只需赋予对该素材的引用,素材一直由 Context 管理,素材本身不需要复制。基本上所有(1)会在不同 IR 子类中传递的内容,(2)需要去重(或者去重对其有益)的内容都在 Context 中。

  2. 文件访问控制。Context 对象会保存一个 FileAccessAuditor 对象,可以通过 Context 对象的 get_file_auditor() 函数获得对其的引用。FileAccessAuditor 会在下面的 4.2 节详细描述。

语涵编译器内基本所有的类实例创建时都需要传递进当前的 Context 对象,基本所有的类实例也都有一个 "context" 属性(Python 的 @property)来保存对 Context 的引用。

除了像字符串这样轻量的字面值外,Context 需要保存当前所有的音频、图片等素材。Context 对象会在创建时生成一个临时目录,用来存放那些没有外部路径、内嵌在文档中的图片等素材。如果有转换生成了新的图片素材(比如什么滤镜效果),那么它们也会被保存在这个临时目录中。临时目录会在 Context 对象生命周期结束前清除。

4.2 FileAccessAuditor 对象

FileAccessAuditor (util/audit.py) 提供了对读取(除了命令行上给出的文件路径)之外所有文件是否被允许访问的检查。让我们先假想一个场景:路人甲在自己的电脑上搭建了一个基于语涵编译器的服务,该服务读取其他人写的剧本并把生成的内容返还给用户。假设这时有好事者乙想窃取路人甲电脑上的其他文件(比如身份证照片),他可以尝试把想窃取的文件以素材的方式在恶意编写的“剧本”中引用。如果语涵编译器傻乎乎地根据“剧本”中的路径把身份证照片给包含到工程目录中,那么当输出的工程目录交给乙的时候,他就成功窃取到了这个图片。为了避免这种情况,语涵编译器作如下假设:

  1. 命令行上给出的所有路径都是可信赖的。在例子中的场景里,命令行上的所有参数(包括这些路径)都应该是由路人甲写的服务提供的,所以都被认为是可信的。

  2. 剧本中的所有资源引用路径都被认为是不可信的、需要进行检查的。在例子中的场景里,这剧本可能是好事者乙写的,有可能存在窃取其他文件、信息的可能。

  3. 如果语涵编译器接入了插件,语涵编译器无法对插件中对外部文件的读写进行干预,运行语涵编译器的人(路人甲)需要确保插件不会偷取外部文件。由于语涵编译器的插件需要通过环境变量进行设置,而环境变量完全在路人甲的控制下,所以只要路人甲没有加入未经检查的第三方插件,就也不会导致上述信息泄露的情况。

传统的C/C++编译器只有在使用 #include (包含其他头文件)的时候可能会有恶意的源代码使用该功能去尝试读取不应该读取的内容。这些编译器要求所有能被 #include 进的头文件都在编译器命令行上写明,编译器不会这些目录之外查找头文件。基于 LLVM 的编译器如果使用了插件(无法保证是否恶意),也是需要在命令行上给出插件路径、显式地启用这些插件的。语涵编译器提供了相同的保护,只要运行语涵编译器的时候不作死,就不会有这种信息泄露的问题。语涵编译器本体内所有的代码(1)在读取文件时只从命令行或 FileAccessAuditor 允许的路径中读取,(2)写入文件时只写到命令行提供的路径或者写到临时目录里。

FileAccessAuditor 类也只会在 Context 对象创建之初创建一个实例,执行语涵编译器时提供的的搜索路径就是被保存在这个类中。目前 FileAccessAuditor 的实现区分以下两种路径:

  1. (全局)搜索路径:当用户在剧本中提供一个相对路径(比如一个没头没尾的"语涵正常微笑")时,除了以剧本本身所在目录为起点外, FileAccessAuditor 会在这些全局搜索路径中一一尝试,直到找到合适的文件。

  2. 可达路径:当剧本中代表一个素材的任意路径(不管是相对路径还是绝对路径)解析完成时, FileAccessAuditor 会判断该路径是否在任意一个可达的路径之下,如果没有一个路径包含这个新素材所在的目录,那么 FileAccessAuditor 会拒绝这个读取尝试。

不过目前(2023-08-09)在命令行上只有一个 "--searchpath" 参数(图形界面里这个叫“素材搜索目录”),该参数同时设置全局搜索路径和可达路径。目前还没有情况需要区分这两者,以后有需要的话会再改命令行参数。

4.3 基础 IR

现在我们开始介绍基础 IR 的设计,详细的代码请参考代码库中的 irbase.py 。基础 IR 中大量使用了多重继承,对于只熟悉C++的读者来说,Python 的类基本上可以理解为只有虚继承(virtual inheritance),区别是继承的所有成员里不能有同名的,同名时只会有一个成员。

由于数据结构中常会需要使用自己的类来做数据结构(比如链表)的结点,基础 IR 定义了以下基类用来替代 Python 给的数据结构:

  1. IList/IListNode. 基础 IR 中使用了大量的双向链表,链表的结点是基础 IR 中各种类的对象。为了保持性能,语涵编译器使用 IListNode 来做这些链表结点的类的基类。(IListNode 中的 “I” 代表 Intrusive, 和 LLVM 里的 simple_ilist 一样,链表的前后指针都是直接在结点本身存储的,而不是链表使用特别的链表结点类,然后再额外带个数据对象;链表的数据和其他数据在同一个 Python 对象里。)同时, IList 类会作为链表本体的类与 IListNode 对接。

  2. NameDict/NameDictNode.基础 IR 中也包含“可以通过名称查找到对象”的结构,就像是一个 Python 的 dict, 键(key)是字符串,值(value)是对象引用。基础 IR 中使用 NameDict 类来存储这些信息,所有会在 NameDict 中作为值的对象将继承自 NameDictNode 。

除了以上提到的区别外,基础 IR 中的数据结构基本都带有 parent 引用;我们可以从链表结点(IListNode)找到链表对象(IList)再找到包含这个链表对象的类(比如操作项什么的)。我们也可以从结点对数据结构进行操作,比如我们可以从 NameDictNode 处把结点移除,其所在的 NameDict 也会更新。

其次,语涵编译器内不使用 Python 的 float, 只使用 decimal.Decimal 。这样保证以十进制小数形式写在剧本里的值(比如渐变时间 0.3 秒)能够没有误差地写到输出中。我们说“浮点数”时,指代的也是 decimal.Decimal 。

还有,基础 IR 中区分“值”(Value)与非值对象,只有值对象才能在基础 IR 中有持久化的引用(指可以保存在数据里),作为其他内容的参数等等。值(Value)在基础 IR 中是个基类,构造时需要提供一个值类型。和 LLVM IR 一样,值有一个 uselist, 每次 Value 新增加一个引用时会新建一个 Use 对象存在这里,我们可以从这个 uselist 找到某个 Value 的所有引用,并提供 LLVM 的 replace_all_uses_with() (把所有对当前值的引用改成对另一个值的引用)这种功能,方便转换与优化。基础 IR 定义了各种字面值类型 (Literal) 来存放 Python 自带数据类型的值,比如 IntLiteral 存放 int 值, FloatLiteral 存放 decimal.Decimal 值, StringLiteral 存放 str 值,等等。

介绍了这些类之后,我们可以开始介绍基础 IR 中的主体:操作项(Operation)。数据中新定义的所有类基本都继承自 Operation 类。一个操作项对象包含:

  1. 一个字符串类型的名称(name)

  2. 一个表示输入位置的元数据(location, 一般用于追踪错误位置,也用来提示素材搜索起始路径)

  3. 0-N 个输入参数 (OpOperand 实例),每个输入参数有一个字符串的名称,可以有 0-N 个输入值(输入值必须是 Value 的子类)

  4. 0-N 个输出结果 (OpResult 实例)。输出结果是 Value 的子类,每个输出结果都有一个字符串的名称。

  5. 一个字典 (Python 的 collections.OrderedDict)存放所有的属性(Attributes)。属性是一个轻量的、保存信息的方式,键是字符串,值可以是整数、字符串、布尔型(True/False),或是浮点数。

  6. 一个存放所有内部的区(Region)的 NameDict. 所有主要内容都在这里。

操作项在构造时需要提供名称和位置。为了保持可拓展,操作项的输入、结果都是按名字的,每个输入都可以有 0-N 个值。不过子类也可以同时继承自 Operation 和 Value, 这样这个对象本身也可以作为值引用。允许输入参数没有值是为了方便可选参数的实现,允许多个值是为了方便(1)代表不定量的参数(比如人物上场命令可以同时上多个人),(2)方便代表有格式信息的文本。语涵编译器使用 StringLiteral 代表一个没有格式信息的字符串,使用 TextFragmentLiteral 代表一个有格式的文本片段,里面有(1)一个对 StringLiteral 的引用来表示内容,(2)一个格式(TextStyleLiteral)表示该片段所有内容使用的文字样式。由于文本有可能是有样式和无样式的交错,要代表“一段文本”需要使用一个列表的 StringLiteral 或者 TextStyleLiteral, 这种情况下他们可以全部塞进一个参数里。

大多数比较简单的操作项都只需在构造时创建输入参数,输入参数就能够表示其内容。比如 RenPy AST 中用来表示一个 play 命令的结点可以写为:

代码10:"play" 命令在 RenPy AST 中的表示 (RenPyPlayNode) (renpy/ast.py)

(上述代码中 RenPyNode 继承自 Operation)

为了便于在代码中创建新的 Operation 子类,irdataop.py 提供了 @IROperationDataclass 和 @IROperationDataclassWithValue 两个修饰符,前一个用于不继承自 Value 的 Operation 子类,后一个用于同时继承两个类的子类。加了这个修饰符之后,就可以像 @dataclasses.dataclass 那样列举成员,初始化时修饰符提供的初始化函数可以自动按照列举的顺序和名称创建对应的项。由于 Operation 的初始化过程比较麻烦 (下面的 4.5 节会细讲),我们推荐所有新写的 Operation 子类使用这两个修饰符。

对于有较多内容的操作项来说(比如代表一整篇输入文档的 IMDocumentOp),使用操作项-区-块-操作项的嵌套结构是期望的保存数据的方式。一个区(Region)单纯只是一个有名字的、保存一串块(Block)的类,一个块(Block)则有以下信息:

  1. 块的名称(字符串)

  2. 一个放 Operation 及其子类的 IList.

  3. 0-N 个块参数 (BlockArgument)。注意这里的“参数”不一定是输入、输出值。语涵编译器内绝大部分地方的块没有块参数(如果您熟悉编译器 IR 的话,这是因为语涵编译器内的 IR 现在还不是 SSA 形式),块参数仅在 VNModel 中使用,后面会讲。

举个例子,刚刚读取完 odt 文档所生成的、代表一整篇输入文档的 IMDocumentOp, 下面有一个叫"body"的区,这个区里有一堆块,每个块代表一个自然段。每个块内可以有 IMElementOp 代表文本、图片等内容,或者 IMListOp 代表一个列表,等等。

语涵编译器还提供了以下 Operation 的子类来提供一些常用功能:

  1. MetadataOp: 语涵编译器一般使用 MetadataOp 的子类(间接继承 Operation)来保存不影响代码生成逻辑的元数据。语涵编译器使用 CommentOp 来记录源剧本中的注释,使用 ErrorOp 来记录处理剧本时的问题、错误等,这两个类都是 MetadataOp 的子类。基本上所有的转换都会将输入 IR 中的 MetadataOp 原封不动地复制到输出 IR 。RenPy 输出中所有 "preppipe_error_sayer_xxx" 的错误信息都是由 ErrorOp 转化而来的。

  2. Symbol(与 SymbolTableRegion 组合): 有时需要把 Operation 作为像 dict 中的值,比如一个工程里有多个函数,我们想有个类似字典的接口,通过函数的名字找到函数对象,这时候函数对象就可以继承自该类。然后使用一个 SymbolTableRegion 来存放这些 Symbol。SymbolTableRegion 是 Region 的一个子类,专门用于这种情况。这种 Region 只会有一个块,块内存的都是 Symbol 。可以把 SymbolTableRegion 当作一个字典来访问其中的 Symbol ,当这些 Symbol 更新(比如移除)时, SymbolTableRegion 也会更新自己的数据。

以上内容大部分都比较抽象、不太直观,下面我们提供一种能够直观检视基础 IR 的方法,并讲述为什么需要这样的基础 IR 。

4.4 基础 IR 提供的功能:检查,复制、保存接口

使用同一套基础 IR 使得语涵编译器能够共享以下功能的实现:

  1. 检视(打印):只要继承了 Operation 类,开发者就可以使用 Operation.dump() 来将该类里所有的信息显示(print)出来,也可以使用 Operation.view() 来保存一个 HTML 格式的输出,程序会把它会保存在一个临时目录下并且尝试打开浏览器去读取它。这两个功能主要在调试器中使用,哪里信息不清楚就 dump 哪里。在管线中,也可以使用 "--view" 选项将当前管线里的 IR 的 HTML 表示给存下来。(注:这个功能需要绕过启动器,直接调用命令行;一般也仅有开发者需要这个功能来辅助调试。)

  2. 统一的复制、保存接口。只要继承了该类,开发者就可以使用 Operation.clone() 来复制一个实例。虽然现在还没有做,但是语涵编译器未来将支持把 IR 保存为 JSON 格式、读取 JSON 格式的 IR, 到时候所有的子类都可以使用基类的实现。

图4:笔者 .vscode/launch.json 里被注释掉的 "--view" 都曾被用于检视管线中的 IR 内容
图5:样例剧本的 VNAST view() 的 HTML 结果

4.5 IRObject

注:这部分只有新建 Operation 子类(或者其他基础类型的子类)才需要阅读,如果只想对已有的 Operation 子类进行操作的话可以跳过该部分。

目前我们可预见的 Operation 的子类和其他基础类型的子类一共有三种创建方式:

  1. 构造:第一次用新数据创建时是这种方式。

  2. 复制:从一个已有的实例复制出一个新的实例时是这种方式。

  3. 导入:以后做了 JSON 导入的话应该可以通过这种方式恢复实例。

但是,Python 只有一个 __init__(),并不像 C++ 那样可以有多个构造函数(constructor)。语涵编译器的解决办法就是自己做一个初始化的机制,做在一个基础类 IRObject 里,包括 Operation 在内的基础类型都继承自该类。具体细节如下:

  1. IRObject 定义以下成员函数: (1) construct_init() 用于第一种创建方式,(2) copy_init() 用于第二种,(3) json_import_init() 用于第三种方式。

  2. IRObject 提供一个泛用的 __init__() ,除了 context 参数外还取一个 init_mode 枚举值来选择以上三个成员函数之一进行调用。

  3. IRObject 额外定义一个 base_init() 来让子类执行一些“不管什么创建方式都应该执行”的代码,和一个 post_init() 来让子类在以上“构造函数”执行完之后再收尾。

  4. Operation 和其他 IRObject 子类覆盖这些成员函数来自定义初始化过程。这些类一定不会覆盖 __init__()。

对于一般的 Operation 子类来说,需要覆盖的成员函数只有 construct_init() 和 post_init(),前一个用来给父类传递合适的 construct_init() 的参数,后一个用来给自己添加成员(因为如果是复制或是导入的话,基类只会把基类的成员加到对象上,子类的成员还不会加)。这里我们以 CommentOp 为例:

代码11: CommentOp 的定义(irbase.py)

基本上所有已有的 Operation 子类都有个叫 create() 的静态函数用于创建实例,他们的内容和代码11中的样例基本上差不多。

5 语涵编译器的抽象 IR 和默认执行管线

前面的基础 IR 讲完后,从这里我们开始讲语涵编译器具体是如何使用这些功能来进行对视觉小说剧本的处理的。下面的图6、图7是目前执行管线(使用启动器时要执行的默认的那串转换)和所用的 Operation 子类的大致说明:

图6:语涵编译器各阶段 IR (前半截)
图7:语涵编译器各阶段 IR (后半截)

后面的内容中我们使用以下最小样例来介绍各个部分:

5.1 IMDocumentOp (InputModel)

为了在未来支持多种输入文件类型,剧本刚刚读取时会把读取的剧本使用一个 IMDocumentOp 来表示,每个 IMDocumentOp 代表一个文件。IMDocumentOp 将输入文档内用到的格式信息保留、转化,其他没有用到的信息全部丢弃,这样可以“抹平”不同输入文档格式之间的差异,使用同一套代码进行后续处理。

原来编写时有一个叫 InputModel 的 Operation 子类用作顶层 Operation,现在已经弃用、删除,但是这个 IR 还是被叫做 InputModel。所有涉及的类都在 inputmodel.py 中定义。

上面的最小样例 "minimal.odt" 在转换为 IMDocumentOp 之后是这样的:

图8:最小样例在 --odf 执行后的 IMDocumentOp

图8就是在初次读取之后的 view() 的结果。文档的内容存放在 "body" 区(Region) 下,每个块(因为没有名称所以都是 <anon>, 匿名)代表一个自然段。第一段是第一行的内容,第二段是第二行的内容。IMElementOp 表示一个正常的文本、图片等内容。如果有列表、特殊块等内容,则在相应的块内有 IMListOp 和 IMSpecialBlockOp 等,这里不再赘述。(我们会在之后整理关于所支持的前端语法的详细说明。)

在文档读取完成之后,下一步是找到文档中的所有命令,并把它们替换为 GeneralCommandOp,把参数等内容都分开。下面是执行这个 "--cmdsyntax" (frontend/commandsyntaxparser.py) 转换后的结果:

图9:最小样例在 --cmdsyntax (图6中的“命令提取”)之后的 IMDocumentOp'

可以看到第二段里的 IMElementOp 被替换为了 GeneralCommandOp, 命令名是“注释”,有一个按位参数 (positional argument),内容是“这确实是最小样例”。

如果有参数是调用表达式的样子(比如有个参数是“占位(分辨率=xxx,描述=xxx)”),那么在 "nested_calls" 区会有一个 GeneralCommandOp 来放这个参数。如果命令后面接了列表或者特殊块,他们会被移到 "extended_data" 区。因为这个例子都没有,所以这两个区都为空。这个命令也没有关键字参数,所以 "keyword_arg" 也为空。

到这之前的所有内容都不涉及视觉小说的具体逻辑;它们可以复用于其他用途。接下来的内容就是涉及视觉小说、目前仍然在寻求反馈阶段的内容了。

5.2 VNAST 和 VNModel

注:VNAST 和 VNModel 仍会在语涵编译器完善过程中迭代,当前内容仅供参考。

在之前的转换中,我们只找到了命令的形式、内容,但是还没联系到命令的含义。在接下来的 "--vnparse" (frontend/commandsemantics.py, frontend/vnmodel/vnparser.py) 转换中,语涵编译器会扫过文档中的所有内容,根据命令内容执行对应的操作。

图10:最小样例在 --vnparse (图6中的“VN命令解析”)之后的 VNAST

这里第一句“语涵:这是最小样例。”被解析为一句发言(VNASTSayNode),第二段的命令转化为了注释 (CommentOp)。由于这个样例没有定义任何函数、章节,所以所有内容都在一个以文件名"minimal"为名称的函数下(VNASTFunction)。这个样例没有定义角色、场景等信息,所以有一堆空白的区。

VNAST (frontend/vnmodel/vnast.py) 是对当前所有剧本的内容的“需求理解”,对标 Clang/LLVM 中的 Clang AST。但是这个信息还不能支持后端 (如 RenPy) 的生成。我们需要先创建一个抽象的演出脚本,这就是 VNModel (vnmodel.py)。VNModel 对标 Clang/LLVM 中的 LLVM IR,我们以后会再对 VNModel 的内容进行梳理。

图11:最小样例在 --vncodegen (图6中的"VN命令语义")之后的 VNModel

5.3 RenPy AST (RenPyModel)

为了方便在导出 RenPy 工程前对其内容进行优化,语涵编译器也对常用的 RenPy 命令建模,做出了删减版的 RenPy AST (renpy/ast.py),其内容基本上可以一一对应到 RenPy 工程输出。

上面的最小样例的 RenPy AST 是这样的:

图12:最小样例在 --renpy-codegen 之后的 RenPyAST

可以看到"script.rpy"(RenPyScriptFileOp) 下有(1) 一个 "define" 语句(RenPyDefineNode) 定义了发言者“语涵”,(2)一个 label start 跳转到下面的 minimal, (3) label minimal, 里面是实际的内容。

总结

本文大致介绍了语涵编译器的工作原理,包括内部的管线、基础 IR, 以及在此基础上的各个子类 IR 和流水线上他们的关系。如果读者对某些源代码感兴趣,可以从本文包含的文件路径出发,或者搜索命令行参数来找到对应的转换注册(@FrontendDecl 这种)。目前基础 IR 和管线没有很强的修改需求,接下来的很长时间内,我们将改进输入剧本的语法以及涉及的 VNAST、VNModel,争取更好的用户体验和生成的演出质量。

感谢您阅读到这!笔者肝不动了,结尾就这样了。。。

附录1:Clang/LLVM 管线中的 Hello world 样例

下面我们以一个 Hello World 程序为基础,介绍一下管线的各个阶段:

注:以下过程仅提供个大致的说明,并不需要读者理解,没有C背景的读者完全可以跳过这里,到下一个部分!!!

代码4:Hello world C 代码 (hello.c)

从此,这个无名的 hello.c 开始了它那鲜为人知的壮阔旅途。

代码5:(删减后的)"clang -E hello.c" 输出结果

这里预处理器把源代码里引用的 stdio.h 的内容全都包含进来了。其中最重要的就是 printf() 的声明。

代码6:(删减后的)Clang AST,"clang -Xclang -ast-dump -c hello.c" 输出结果。

从这开始,这些显示内容都是内部数据结构的显示结果,不需要再进行文本处理。这里的 FunctionDecl 对应函数(function) int main() 的声明(Declaration),CompoundStmt  对应 main() 后面的花括号 {} (也代表函数本体内容), CompoundStmt 下有一项 CallExpr 表示对 printf() 的调用,另一项 ReturnStmt 代表返回语句。

代码7:未优化的 LLVM IR ("clang -S -emit-llvm hello.c -o hello.ll" 后 hello.ll 的内容节选)

LLVM IR 代表抽象的程序实现。字符串(Hello world)的内容被放在只读的常量区,对其的引用转化为了地址(@.str)。这里 %2 = call ... @printf(...) 是 printf 的调用,下面的 ret i32 0 是返回语句生成的内容。上面的 %1 = ... 和下面的 store 指令没有用处。

代码8:优化的 LLVM IR ("clang -O1 -S -emit-llvm hello.c -o hello.ll" 后 hello.ll 的内容节选)

可以看到这里 LLVM 把对 printf() 的调用优化为对 puts() 的调用,上面两个没用的指令也被去掉了。到这里,所有的代码基本都是平台无关的,除了x86-64外,如果新加了平台(比如手机用的ARM),也可以复用这些优化。

代码9:x86-64 MIR, "llc -print-after-all hello.ll" 输出节选。

从这里开始就是x86-64独有的部分。MIR阶段也有多个步骤,当前步骤下所有的寄存器已经确定(比如 $eax 做返回值,调用 puts() 之前,"hello world"字符串的地址放在 $rdi 里面),指令也已选定。

代码10:最后的汇编结果。

语涵编译器面向程序员的介绍——管线,基础IR,以及默认管线中的各个抽象的评论 (共 条)

分享到微博请遵守国家法律