编译器LLVM-MLIR-Intrinics-llvm backend-instruction
参考文献链接
https://mp.weixin.qq.com/s/G36IllLOTXXbc4LagbNH9Q
https://mp.weixin.qq.com/s/5LW3TQFsEEnWiGF5lXZV5A
https://mp.weixin.qq.com/s/WcRRy4qJE8n_DFYf8a5INA
LLVM IR,SPIR-V到MLIR
机器学习编译器(ML Compiler)的实践刚开始不久。经过几年的探索和演进,ML Compiler已经成为ML System中最重要的一环。ML Compiler既有其自身的特点,又和传统编译器有着密切的联系;既有新的挑战,又有很多前人的经验可以借鉴,是个有趣且有高价值的话题。
编译器 (Compiler) 通常是各种提高开发效率的软件工具链中不可或缺的部分。编译器一般被认为是黑箱,吃进高抽象层次的源程序,产生语义不变,可在目标硬件上运行的低层次机器码。当然,编译器也有其内部结构,中间表示 (IR: Intermediate Representation) 串联起编译器内各层级和模块。
中间表示对编译器至关重要,也如编译器一样百花齐放。在日常工作中有幸能够涉及三种主流编译器中间表示或者基础设施——LLVM IR, SPIR-V, 以及 MLIR, 尤其对于后两种,都参与了早期的开发。
编译器与中间表示
在讨论各种具体中间表示之前,先让总体看一下编译器和中间表示。
抽象与语义(Abstractions and Semantics)
自人类文明产生至今,虽然科技演进的速度飞快,人脑却没有多少变化。理解不断爆炸式提升的复杂度的方式是抽象 (abstraction)。抽象帮助忽略细枝末节,着眼于主要矛盾。抽象减少了人脑需要处理的变量数量,减轻了人脑的处理负担,这对于求解复杂问题至关重要。自抽象而产生的问题描述被称为模型 (model),例如,在编程语言中,有机器模型 (machine model) 用以描述底层计算架构;在数据系统中,有数据模型 (data model) 用以描述数据关系结构;在分布式系统中,有系统模型 (system model) 用以描述时序和容错假设。模型通常是原体的理想化描述。有时模型会离现实情况非常远,例如,估计没人能够设计准确的股市模型。但无论如何,模型始终是保持复杂度可控不可或缺的工具——模型能够给清晰明确的语义 (semantics), 即因定义而一定成立的原理。有了这些,才能对模型所描述的原体进行逻辑甚至数学层次的严谨推理 (reasoning)。人脑本质性喜欢逻辑与解释(神话、科学、甚至迷信都是人类试图解释世界的方式)。此处似乎离题了,实际上只是在用广义的方式引入一些常见的编译器开发术语——抽象、模型、语义、推理。当然,计算机可行的其它领域也会关注这些方面,比如,希望面向对象的“类”有良好的抽象,但这些领域通常多是“设计艺术”。相对而言,编译器更多关注理论科学的方面;编译器需要明确的语义用来证明 (prove) 代码转换的正确性。这就引入了——
正确性与优化(Correctness and Optimization)
编译器的首要任务是保证转换的正确性 (correctness);优化 (optimization) 是次要的考虑。产生不符合源程序意图的非常“优化”的代码没有任何意义。在整个编译流程中正确性都需要得到保障。离开清晰的语义,正确性就无法得到定义和保证。因此编译器对语义未知的操作 (operation) 无法进行任何转换。当然,这并不包括每一个微小转换,编译器内部也有不同层次的“边界”。每个转换遍历 (pass) 保持原子性;在其内部,可能会临时违反源程序语义,但在每个转换遍历之后,中间表示应该是正确的。编译器极度依赖每个遍历之后的中间表示验证 (validation) 来保证正确性。在正确性得到保障之后,才可以考虑优化。生成高性能代码似乎是无可争议的目标,但实际上也充满了微妙的细节。不同的源程序有着不同的模式,不同的硬件喜欢不同的指令流。编译器处于源程序和目标硬件之间,实际上能够动用来提升性能的手段是非常有限的。编译器需要在大量的限制因素下做抉择,并且保证这些抉择对大多数场景适用,特别是针对 C++ 这种通用编程语言。这其实是非常高的要求,导致最后只有少数一些转换(像是不可达代码消除DCE、常量折叠constant folding、指令标准化canonicalization等等)可以全局默认开启,很多其他转换只能在之后的第二阶段,针对目标硬件优化时使用。在编译器一步步转换程序的过程中,越来越多的高层次的简明的信息被打散,转换成低层次的细碎的指令,这个过程被称为代码表示的递降 (lowering)(不知道公认的中文翻译是啥,只能自己来了。);与之相反的过程被称为代码表示递升 (raising)。后者通常远比前者困难,因为后者需要在芜杂的细节中找出宏观脉络。代码表示递降是编译器的通常转换方式。不难理解,越晚执行的转换越有结构性劣势,原因是缺乏高层次信息。这限制了目标硬件相关优化所能解决的问题也决定了实现的复杂程度。其实这里的本质性问题是强耦合 (coupling)。这种强耦合绑定了不同应用领域(尤其是使用通用编程语言的情况)和编译器中不同垂直转换路径(尤其是编译器使用统一IR的情况)。解耦 (decoupling) 是在各种领域中实现更加复杂系统和支持更加高级场景的一般方式。可以预见,领域专用语言以及编译器内部解耦能够更好地释放编译器的优化能力。在接下来的 MLIR 章节会进一步展开这一点。总而言之,成熟的编译器可能会帮助其支持的各种场景挖掘出大部分的性能,比如说是 80%,但期待编译器对所有应用达到最优性能是不切实际的。编译器的真正优势是帮助节省达到前 80% 性能所需的工程投入,让可以集中资源在剩下的 20% 或者其他核心问题上。
效率工具
尽管已经近乎陈词滥调,还是再一次强调,编译器只是提升开发效率的工具。写汇编码或者机器码也许会显得特别酷,却很难是高效的开发方式(不过,对于某些适合的场景,通过手写汇编来获取极限性能却是非常合理和常见的方式)。 能够使用高层次抽象的语言可以让开发者忽略底层芯片上具体的寄存器和指令,从而避免陷入耗时易错的繁杂细节之中,是对开发效率的极大提升。这就涉及管理不断提升的复杂度的方式——有了针对不同层次的抽象,只需要创建工具来对这些抽象进行自动转换。当然,并非所有的转换都可以自动化。对于可行的,都可以把这种工具看作广义上的编译器。比如,在数据处理系统中,Apache Beam 提供了统一的语言来描述任务。Beam 会把这些任务转换到具体的执行引擎 (Spark, Flink, 或者其他) 的描述。这些执行引擎再进一步把这些任务编译成运行在具体机器上的操作并负责调度。这里的整个流程也可以看做是一种编译。类编译器的工具通过隐藏底层细节、提供高层次抽象而极大地提升了开发者的效率。编译器通过对中间表示进行一系列变换 (transformation) 来链接不同层次的抽象。
IR的形态和兼容性
中间表示只是程序的一种表示,其设计注重支持变换操作,使其正确和高效。当然,后面也会发现中间表示也会实现一些其他目标。在早期,编译器通常有单一中间表示,但随着编译器的演进,情况变得更加复杂。现代编译器通常会有多层级的内部表示。比如,用 Clang 来编译 C++ 程序通常要经过 Clang AST, LLVM IR, MachineInstr, MC 等等多个层级[1]。针对 GPU,也会发现完整编译器被拆分成离线 (offline) 和在线 (online) 两部分。两部分之间的程序表示,像是 SPIR-V,也会被称为中间表示,但其实已经不再局限于编译器内部了。中间表示可以有三种形态:用以高效分析和变换的内存表示 (in-memory form),用以存储和交换的字节码 (bytecode form),以及用以阅读和纠错的文本表示 (textural form)。基于使用场景,通常会见到不同的设计折中来侧重不同的形态和提供不同程度的兼容性。比如,LLVM IR 更侧重于编译器内部使用,所以核心是高效的内存表示并提供相对较弱的兼容性以便于迭代改进。而 SPIR-V 则设计为硬件驱动的输入程序表示,所以更注重于字节码的高效处理并提供强兼容性。此处无所谓对与错,只是满足不同的需求而已。但这些设计折衷依然对整个上层的生态系统产生深远影响。
IR的设计理念
现实中并不存在中间表示的普适设计规则。大部分情况下,中间表示的设计都是在各种限制条件下权衡各种利弊然后做出折中选择。这些选择会考虑具体问题的普遍性或者特殊性、给整个编译器栈带来的组合复杂性、对各种变换的影响等等。总体上,希望:
- 中间表示中的操作 (operation) 能够具有清晰明确的语义。这是一切的根基。
- 在这之上希望操作能够相互正交 (orthogonal),这有助于定义标准 (canonical) 形态以减少变换需要考虑的情况。
- 另外也会希望在同一中间表示的不同部分中避免出现重复信息。重复信息在各种变换后有很大概率会导致中间表示的不一致,从而导致错误编译。
- 尽可能地保持高层次信息也是非常有好处的,因为在代码表示递降后想重新找回丢失的信息很难。
- 以及其他种种。
听起来似乎都是非常有道理的指导哈?其实对上面的大部分理念都可以举出反例: - 如果硬件实现了某特殊的功能模块,希望能够提供相应的软件和中间表示抽象, 即便这会“破坏”中间表示的正交性。举个栗子,在 GPU 中间表示中,除了基础的乘和加指令,都有 FMA 指令。
- 在 SPIR-V 中,一个编译单元 (module) 会提前声明所需要的硬件能力 (capability)。这些信息完全是可以通过对中间表示的主体进行分析来得到的,所以是重复的信息。但是存在减少了硬件驱动所需做的工作,从而加速了实际运行。
- 如果输入语言已经足够低层次,比如 C,那么没有任何办法,只能想办法提升抽象的层次来产生更好的代码,像是对标量代码的自动向量化 (vectorization)。
以上其实只是试图说明中间表示的设计真的充满了依据领域和场景的折衷。如之前所述,如果同一个编译器试图支持多种应用,或者同一中间表示试图支持多个垂直转换路径,非常容易面临相互冲突的目标,使在讨论中达成一致变得很困难。这也意味着丧失了迅速迭代和演进的能力,在有些情况下代价很高。对编译器进行解绑 (unbundling)——面向特定领域、不同层级和模块化的中间表示——非常有用。这样每个领域可以以最符合其领域特征的方式来设计编译器,不同层级和模块化的中间表示也可以只关注场景来做最合适的取舍。
LLVM IR
LLVM 最初发布于 2003 年。在经过了接近二十年的开发之后,LLVM 技术栈已经非常成熟,并有一个极好的生态系统。LLVM 支持许多前端语言和后端硬件,许多软硬件厂商有衍生版 LLVM 来针对自己产品的提供各种附加功能。想 LLVM 对于整个业界的重要性无需再赘言。
解绑和模块化编译器(Decoupling and modularizing compilers)
LLVM 带来的最重要的东西是对编译器解绑和模块化的实践,由此诞生了大量优秀的编译器库和工具。在前 LLVM 时代,编译器通常是特殊设计并高度耦合。这些编译器虽然也分为三段——前端 (frontend) 用来对源语言进行解析,中端用来进行优化,后端 (backend) 用来产生机器码,但一般只针对某一特定语言(含衍生语言)或者目标硬件,编译器内部各个模块也没有明确界限。不同的编译器栈基本不共用代码,无法组合不同编译器栈中的现存的前端或者后端,从而无法真正发挥三段式编译器的优势。LLVM 依靠解绑带来巨大变革。LLVM IR [2]显而易见地处于 LLVM 生态的核心地位。LLVM IR 使用控制流 (control flow)、基础块 (basic block)、以及静态单赋值 (SSA) 形式来表示程序。这种表示是完备的,LLVM 从而可以独立于其他表示形态,实现作为前后端之间的单一桥梁。这就完全地解绑了编译器的前后端。[3]这之后所需的只是遵循模块化的最佳实践。LLVM 的代码是组织成一系列库 (library) 的。库的组织形式当然有其问题,但确实是经过实践检验的系统级模块化方式。库定义了不同模块之间的分界。通过合适的编程接口 (API),可以选择并且组合不同的编译器功能来完成不同的任务, 像是通过调用 Clang 功能库实现静态分析以及代码格式化。这些都是非常有用的。
文本IR形式(Textural IR forms)
除却解绑和模块化,LLVM IR 还带来了许多其他易用性和开发效率上的提升。在内存表示之外,对文本表示的原生支持将传统的 UNIX 哲学带入了编译器。UNIX 哲学讲求每个工具负责一个简单任务,然后利用文件 (file) 以及管道 (pipe) 串联不同工具来实现复杂功能。在 UNIX 系统中,文件是对资源的统一抽象。尤其是文本文件,基本是大多数工具的信息交换媒介。文本文件足够灵活强大,能够支持不同的需求,同时又直观易用。在一个处理流程中(例如cat | cut -f2 | sort | uniq -c)打印中间状态是非常自然的查看和纠错手段。长远来看,简单易用无可匹敌。当然,编译器的组件即便是模块化后也很难说是“简单”的工具。但 LLVM IR 的文本表示实现了 UNIX 文件一样的用途,能够串联起各个转换遍历 (pass),方便查看中间状态。这自然延伸到使用 FileCheck 来测试编译器本身,因为现在可以输入可读的文本表示并检查其输出。
一体两面
LLVM 是编译器开发的大幅跃进。其良好的设计和活跃的社区带来了许多提升效率的工具。但凡事都有一体两面。基于现有的 LLVM 生态,可以越来越明显地看到有些设计折中带来的影响。
中心化和各种衍生
LLVM IR 在整个 LLVM 生态中处于中心地位,这是编译器前后端解绑的基础,但这也意味着完整的编译路径必须通过 LLVM IR。因为 LLVM IR 的核心地位,对其进行修改需要满足极高的条件。各种工具都基于 LLVM IR,各种公司或者组织的内部流程都会通过实现。即便无需频繁地打通整个路径,对 LLVM IR 的局部小修改也会带来意想不到的间接效应。自然而然,这就意味着改动缓慢,需要长时间高强度的讨论,以及各利益相关者的同意。这对于保持 LLVM IR 本身的质量是必须的,但如果只是有一个非常特殊的需求, 就很难劝服整个生态做出相应的修改。一种常见的方式是分裂 LLVM,把修改保持在本地,创建衍生版。这依然会有很高的工程代价。LLVM IR 的中心地位对把所有改动贡献回上游更加友好。LLVM 的代码库每天有将近一百次提交,这些提交添加各种新功能或者修复各种问题。如果不及时追踪上游的提交,衍生版会偏离的越来越严重,最后可能无法再合并。另一方面,持续不断地追踪则意味着专门的人力和资源投入,很多小机构无法一直负担。总而言之,后果就是在现实中有各式各样的 LLVM 衍生代码,这些代码追踪着不同的上游版本, 有着不同的新鲜度。如果把全球作为一个整体来看,维持和更新这些衍生代码消耗了大量的人力和资源。当然这并不能说是 LLVM 独有的问题。以开源模式开发的大规模复杂系统在被各种组织商业化的时候都有类似问题。但类似项目通常会在设计时就考虑本地定制化的需求。相较而言,LLVM IR 的绝对中心地位使得其很难被本地定制化。某种意义上讲,这就是一种强耦合;MLIR 就在解耦上再进一步。
演进与兼容性
LLVM IR 的另一设计是能够协同演进中间表示和各种编译器分析以及变换。这对于工具链本身质量的提升是至关重要的,但同时带来了相对较弱的兼容性保证[4]。当然社区不会无缘无故引入不兼容的变动,只是这一可能性确实存在。
编译器存在于近乎操作系统的层级,所以很容易理解人们为什么使用 LLVM IR 来作为程序的表示形式传送给硬件驱动,特别是考虑到 LLVM IR 极好的生态系统以及原生的字节码支持。但是 LLVM IR 真正的使用场景是作为不同软件模块之间的程序表示;涉及硬件和驱动则完全是另一个故事。硬件设备会存在于像是手机之类的终端产品之中,这些终端产品被产品制造商和最终消费者所掌控。驱动的升级是无法得到保障的,因此驱动依赖的 LLVM 库也可能永远无法得到升级。
实际上已经有许多如此使用 LLVM IR 的尝试,有成功,也有失败。一个比较突出的例子是 Standard Portable Intermediate Representation (SPIR)。SPIR 是为表示 OpenCL 设备程序 (kernel) 而设计的,锁定了某一版本的 LLVM IR,使用 LLVM 内联函数 (intrinsic) 和元数据 (metadata) 来定义 OpenCL 的计算原语以及定义。但 Khronos Group 逐渐意识到 LLVM IR 实在不适合这种任务,遂转向了设计与开发 SPIR-V。
SPIR-V
SPIR-V 最初发布于 2015 年。SPIR-V 是多个 Khronos API 共用的中间语言,包括 Vulkan, OpenGL, 以及 OpenCL。定义全新的中间表示并且开发整套工具链需要大量的工作,但对 SPIR-V 的投入依然被认为是值得的。
标准规范的扩展性和兼容性
Khronos Group 的标语是“连接软件与硬件”,简明扼要地总结了任务。这种连接是通过标准规范 (standard) 和编程接口。Khronos Group 定义标准规范以及编程接口;硬件厂商提供硬件实现,软件厂商则可以让软件在所有支持的平台与设备上运行。Khronos Group 定义维护了很多标准规范,比较著名的有 Vulkan, OpenGL, 以及 OpenCL。标准规范的主要目的是抽象不同的硬件实现,并提供对上层软件的统一接口。但同时,标准规范也需要能够支持硬件特有的功能。这是对现实中存在各式各样硬件的一种承认,也让软件能够深度挖掘某些具体硬件的性能。这两个看似相互冲突的目标通过分等级的特性 (feature) 体系 (hierarchy) 得以支持。Khronos Group 内部有清晰的流程来管理各种特性,包括提议新的特性,提升某厂商专有的特性为通用特性等等。SPIR-V 也是一种标准,所以具有相同的设置。除了核心特性之外,SPIR-V 支持通过多种机制来扩展其功能,包括添加新的枚举值, 引入新的扩展 (extensio