vc2010解决方案项目编译顺序_[翻译]MLIR:摩尔定律终结的编译器基础结构

[翻译]MLIR:摩尔定律终结的编译器基础结构

MLIR:A Compiler Infrastructure for the End of Moore's Law

作者:Chris Lattner等

翻译:汪岩

[摘要]本文提出了MLIR,这是一种构建可重用、可扩展编译器基础结构的新方法。MLIR旨在解决软件碎片化,改进异构硬件的编译过程,大大降低了构建领域特定编译器的成本,并有助于将现有的编译器连接在一起。MLIR还有助于在不同抽象级别、不同跨应用程序域、不同硬件目标和执行环境,改善代码生成器、翻译器和优化器的设计和实现。本文的贡献包括(1)讨论MLIR作为本文研究成果可能的扩展和进化,并指出这个新设计方法在设计、语义、优化规范、系统和工程等方面带来的挑战和机遇。(2)评估MLIR作为可减少构建编译器成本的通用架构,通过描述各种用例,显示本文研究成果在未来编程语言、编译器、执行环境和计算机体系结构方面的研究和教学机会。本文还介绍了MLIR设计基本原理、结构和语义。

  1. 简介

编译器设计是一个成熟的领域,包括许多广为人知的算法,可用于代码生成、静态分析、程序转换等。编译器设计领域已发展出许多成熟技术平台,这些平台现在已经在整个编译器社区大规模应用,包括LLVM编译器基础结构[25]、Java虚拟机(JVM)[26]等系统。这些流行系统的一个共同特征是它们的“one size fits all”方法,即与系统接口的是单一抽象级别,例如LLVM中间表示(IR)大致是“带有向量的C(C with vectors)”,而JVM提供了“具有垃圾收集器的面向对象类型系统(object-oriented type system with a garbage collector)”抽象。这种“one size fits all”的方法非常有价值,因为从源语言(C/C ++和Java)到这些抽象领域的映射非常直接。

同时应该指出,许多问题在较高或较低级别的抽象层级建模会更好。例如在LLVM IR上,对C ++代码进行源代码级分析非常困难。我们注意到,许多语言(例如Swift,Rust,Julia,Fortran)都开发了自己的IR,以解决这些语言领域特定的问题,例如语言/库相关的优化、流敏感( flow-sensitive)类型检查(例如线性类型)和改善降级(lowering)流程的实现。类似地,机器学习系统通常将“ML图”用作领域特定的抽象。

尽管领域特定IR的开发是一项已经被充分研究的技术,但其工程和实现成本仍然很高。对于这些系统的实现者而言,有时候基础结构的质量不一定是优先考虑的因素。这可能导致编译器系统的实现质量降低,这包括一些用户可见的问题,例如编译时间慢、错误的实现、诊断质量欠佳、优化代码的调试体验差等等。

MLIR项目的目的就是要应对这些编程语言设计和实现方面的挑战,采用的方法是通过非常简便地定义和引入新的抽象级别,并提供“现成的(in the box)”基础架构来解决常见的编译器工程问题。 MLIR的做法是:(1)标准化基于静态单赋值(SSA)的IR数据结构,(2)提供用于定义IR dialect的声明系统,(3)提供广泛的通用基础结构(包括文档、解析和打印逻辑、位置跟踪、多线程编译支持、pass管理等)。

本文探讨了MLIR系统的各个设计要点,将我们的经验应用于不同的问题,并讨论了这项工作可能对编程语言设计和教学产生的影响。

1.1 贡献

尽管大多数MLIR系统是由广为人知的编译器算法构建,但MLIR的设计要点很新颖,为其它研究提供了机会。本文的贡献是:

  • 描述了一种对工业界和学术界有重要应用价值的新型编译器基础结构;
  • 提出了一种构建可扩展和模块化编译器系统的新方法;
  • 选择了一些MLIR在不同领域的应用,说明了系统的通用性;
  • 分享了在MLIR基础架构上开发编译系统的经验。

1.2 MLIR起源

之所以会发起MLIR,首先是意识到现代机器学习框架由许多不同的编译器、图技术和运行时系统组成(请参见图1),但是这些部分没有共享公共的基础结构或设计观点,而且有些部分没有遵循最佳编译器设计实践,导致的后果是用户可以明显感觉到不便,包括不完善的错误消息、边界情况下的错误、不可预测的性能,以及难以支持新硬件。

7616ad40baf0931cbc9bc0dbc50e6c85.png

我们很快意识到,整个编译器行业都存在一个类似的问题,那就是,诸如LLVM之类的现有编译系统在跨多语言实现的统一和集成方面非常成功,但是现代高级语言通常最终会构建自己的高级IR,并重复发明许多相同的更高层抽象技术(请参见图2)。同时,在LLVM社区经常出现一些争论,比如,如何最好地表示并行结构,如何共享常见的前端降级基础架构实现(例如,用于C调用约定或诸如OpenMP之类的跨语言功能),但都没有得出令人满意的解决方案。

4bd9f3baaad0bc1cd5f7865e80858f6f.png

面对这些挑战,我们认为我们无法承担实现N个改进的编译器的工作量,因此我们需要构建一个更通用的解决方案。我们可以花精力开发一套高质量的基础架构,这会让多个领域受益,会让我们能够逐步升级现有系统,让我们能够更轻松地解决眼下紧迫的问题,例如专用加速器的异构编译。

现在,我们在构建和部署基于MLIR的系统方面积累了大量经验,可以回顾一下MLIR基础架构的原理和设计,并讨论为什么要朝这个方向发展。

2. 设计原理

本节探讨了指导MLIR设计的需求。

内置少,一切可定制(Little builtin, everything customizable)

MLIR系统基于最少量的基本概念,大部分中间表示都完全可定制。在设计时,应当用少量抽象(类型、操作和属性,这是IR中最常见的)表示其它所有内容,从而可以使抽象更少、更一致,也让这些抽象易于理解、扩展和采用。从广义上讲,可定制性确保编译系统可以适应不断变化的需求,并且更有可能适用于未来的问题。从这个意义上讲,我们应该将IR构建为支持其中间语言的语法和语义、具有可重用组件和编程抽象的基础结构。

定制化成功的标准是可以表达多种抽象,包括机器学习图、AST、数学抽象(例如多面体)、控制流图(CFG)和指令级IR(例如LLVM IR),而且从抽象到编译系统无需使用任何硬编码概念。

当然,由于兼容性不佳,可定制性会带来内部碎片的风险。虽然不可能有一种纯粹的技术解决方案来解决生态系统碎片化问题,但系统应鼓励设计可重用抽象,并假定这些抽象会在设计的预料范围之外使用。

SSA和regions

静态单赋值形式[15]是编译器IR中广泛使用的表示形式。静态单赋值有许多优点,包括使数据流分析变得简单而稀疏,并且由于静态单赋值与延续传递风格(Continuation-Passing Style, CPS))的关系,编译器开发社区对静态单赋值已有广泛理解,并且已被应用到主要框架中。尽管许多现有的IR使用扁平的(flat)、线性CFG,但代表更高级别的抽象却推动将嵌套区域(nested regions)作为IR中最重要的概念。这超越了传统的区域形式,提升了抽象级别(例如,loop trees),加快了编译过程、指令提取或SIMD并行性[22,21,37]。为了支持异构编译,系统必须支持结构化控制流、并发构造、源语言中的闭包等等。一个具体的挑战就是在嵌套区域之上构造CFG分析和转换。

在此过程中,会牺牲LLVM的归一化(normalization),有时甚至牺牲其规范化(canonicalization)属性。能够将各种数据和控制结构降级为更小的归一化(normalized)表示集合,这对于控制编译器的复杂性至为重要。具有pre-header、header、latch、body的规范循环(canonical loop)结构是前端语言中各种循环构造的线性化控制流表示的典型情况。MLIR的目的是为用户提供一种选择,即,根据编译流程中pass的编译算法,可以将嵌套循环捕获为嵌套区域或线性化控制流。通过提供这种选择,我们可以脱离LLVM的normalization-only方向,同时保留了在必要时处理更高级别抽象的能力。反过来,采用MLIR的这些方法也产生了如何控制抽象规范化(normalization)的问题,这是下一段的主题。

【译注:“规范(canonical)”形式和“归一(normal)”形式之间的区别因不同领域而异。通常,规范形式为每个对象指定唯一的表示形式,而归一形式仅指定其形式,而无需唯一。因此,规范化比归一化要求更严格】

渐进式降级(Progressive lowering)

编译系统应支持渐进式降级,即,以较小的步幅,依次经过多个抽象级别,从较高级别的表示降低到最低级别。需要多层抽象是因为通用编译器基础结构必须支持多种平台和编程模型。

以前的编译器已经在其pipeline中引入了多个固定的抽象级别,例如Open64 WHIRL表示[30]具有五个级别,Clang/LLVM编译器从AST降级到LLVM IR、SelectionDAG、MachineInstr和MCInst。上述降级实现方式较为僵化,因而需要更灵活的设计来支持抽象级别的可扩展性。

这对转换不同阶段的排序有深刻影响。随着编译器专家们实现越来越多的转换pass,这些pass之间开始出现复杂交互。实际情况表明,将优化pass结合起来运行可以使编译器发现更多的程序有用信息。能说明合并pass好处的例子是混合常量传播、值编号(value numbering)和不可达代码消除的尝试[13]。一般而言,编译器pass可大致分为四个角色:(1)优化转换,(2)启用转换,(3)降级,(4)清理。编译系统应该允许在单个操作的粒度上混合和匹配这些角色,而不是在整个编译单元上顺序执行这些pass。

维护高级语义

系统需要保留分析或优化性能所需的高级语义和计算结构。一旦降低语义,再试图提高语义会很难成功,并且将这种信息强行塞进一个低层次IR的环境中(例如,在使用调试信息来记录数据结构的情况下,所有pass都需要进行验证/重新访问)通常都有破坏性。相反,系统应保持计算结构并逐步降级到硬件抽象。这时,可以有意识的丢弃结构信息,并且这种丢弃只在不再需要此结构来匹配基础执行模型的情况下才会发生。例如,系统应在整个相关转换过程中保留结构化的控制流,例如循环结构。删除此结构,即转到基于CFG的控制流,实质上意味着将不再在此级别上执行任何转换。在编译器开发中,并行计算结构建模领域的最新进展现实这类任务有相当的难度[23,42]。

为了允许编译系统的一部分IR保留较高层级的抽象,而另一部分被降低IR层级,在同一IR中混合不同级别的抽象和不同概念必然成为系统的关键属性。比如,自定义加速器的编译器可以在IR中复用系统定义的一些高级结构和抽象,IR同时也可表达加速器特有的基本标量/矢量指令。

IR验证

生态系统的开放性要求有宽泛的验证机制。验证和测试不仅对于检测编译器错误很有用,而且在可扩展的系统中,对验证方法和工具健壮性的需求也在不断提高。验证机制应使得定义简洁和实用,并可以作为正确结果的唯一来源。

验证机制的长期目标是复制翻译验证[35,29,50,51]和现代编译器测试方法[12]的成功经验。在可扩展的编译器生态系统中,验证和测试都还是有待解决的两个问题。

声明式重写模式(Declarative rewrite patterns)

定义IR修饰符应该和定义新抽象一样简单;通用转换应可实现为声明式表达的重写规则,并以机器可分析的格式推理出重写的属性,例如复杂性和完成度。重写系统的健全性和效率很高,因此被广泛研究,并已被应用于从类型系统(type systems)到指令选择的众多编译问题。MLIR的目标是实现前所未有的可扩展性和渐进降级功能,可以通过许多途径将程序转换建模为重写系统。这也提出了如何表示重写规则和策略,以及如何构建机器描述的问题。这里的机器描述应能够通过多个抽象级别引导重写策略。系统需要在解决这些问题的同时,保持可扩展性并执行合理、单调和可复制的行为。

源位置跟踪和可追溯性(Source location tracking and traceability)

操作的来源(包括其原始位置和应用的转换)应易于在系统中追溯。这是为了解决在复杂编译系统中常见的缺乏透明性问题,而在复杂编译系统中,很难了解最终表示是如何从原始表示中构造出来的完整过程。在编译安全性至关重要的敏感应用程序时,这是一个突出的问题,在这类程序中,跟踪降级和优化步骤是软件认证程序的重要组成部分[43]。当使用安全代码(例如加密协议,或对隐私敏感的数据进行操作的算法)进行操作时,编译器常会碰到看似冗余或繁琐的计算,这些计算会嵌入未被源程序的功能语义完全捕获的安全性或私有属性,而安全代码可以防止旁路暴露或加强代码以防止网络攻击或故障攻击。优化可能会改变或使此类保护完全失效[56];这种缺乏透明性在安全编译中称为WYSINWYX[6]。准确地将高层次信息传播到较低层的一个间接目标就是帮助实现安全且可追溯的编译过程。

3. IR设计细节

本节根据上一节中阐述的原理,介绍MLIR中IR的设计。

操作

MLIR中的语义单位是一个“操作”,称为Op。在MLIR系统中,从指令到函数再到模块,一切都建模为Op。 MLIR没有固定的Op集合,因此允许并鼓励用户自定义扩展Op。编译器pass会保守地对待未知Op,并且MLIR支持通过特征(traits)、特权操作hook和优化接口等方式向pass描述Op语义,详见6.1节。

Op(见图3)具有唯一的操作码(opcode)。从字面上看,操作码是一个字符串,用于标识其dialect和操作。Op可以有零个或多个值作为操作数和结果,并以静态单赋值的形式维护操作数和结果。所有值都有一个类型,类似于LLVM IR。除了操作码、操作数和结果外,Op还可能具有属性、区域、块参数和位置信息(Attributes, Regions, Block Arguments, and Location Information)。图4说明了值和Op,%标识符是命名值(包),如果包中有多个值,“:”后指定包中值的数量(注:如下例中“%results:2”,表示返回值有2个),而“#”表示特定值。在一般的文本表示形式中,操作名称是用引号括起来的字符串,后跟括号括起来操作数。

0fffd0ab5fea43934098cc7d2e816470.png

【译注:

  • %results:操作结果变量名,遵循静态单赋值规则。
  • d.operation:操作名称,是一个唯一的字符串,不能重用。“.”前的“d”是dialect的namespace,“.”后的“operation”是dialect的操作名。
  • %arg0, %arg1:操作输入的变量名列表。输入变量由其它操作定义或者是块的参数,符合静态单赋值规则。
  • { attribute = “value” }:属性列表,一般是常量输入。
  • () ->(!d.type, !d.other_type, ):函数类型,箭头前的是参数类型,本例中为空;箭头后的是返回值类型。

c2fb1663927e9e2cb6baf154da642c0f.png

属性

MLIR属性是结构化的编译期静态信息,例如整数常量值、字符串数据或常量浮点值列表都是MLIR属性。属性有类型,每个Op实例都有一个从字符串名称到属性值的开放键值对字典映射。

在通用语法描述中,属性在Op操作数和其类型之间,键值对列表中的不同键值对用逗号分隔,并用大括号将整个键值对列表括起来。

例如,图4使用属性来定义已知为常量仿射形式的循环边界:

{lower_bound = () -> (0), step = 1 : index, upper_bound = #map3}

其中,lower_bound、step和upper_bound是属性名称。() -> (0)标识用于内联仿射形式。这个例子是产生常数0的仿射函数。#map3标识用于属性别名,该属性别名允许将属性值与标签预先关联,并可以在任何需要属性值的地方使用标签。

与操作码一样,MLIR没有固定的属性集。属性的含义由Op语义或与属性相关的dialect (见第3节)中得出。属性也是可扩展的,允许直接引用外部数据结构,这对于与现有系统集成很有帮助。例如,某个属性可以引用ML系统中(在编译期已知的)数据存储的内容。

位置信息(Location information)

MLIR提供了位置信息的紧凑表示形式,并鼓励在整个系统中处理和传播位置信息。位置信息可用于保留产生Op的源程序堆栈踪迹,用以生成调试信息。位置信息使编译器产生诊断信息的方式变得标准化,并可用于各种测试工具。位置信息也是可扩展的,允许编译器引用现有的位置跟踪系统、高级AST节点、LLVM风格的文件-行-列(file-line-column )地址、DWARF调试信息或其它高质量编译实现所需的信息。

区域和块(Regions and blocks)

Op的实例可能附有一系列附加区域。区域为MLIR中的嵌套结构提供了实现机制:一个区域包含一系列块,一个块包含一系列操作(操作中可能又包含区域,如图三所示)。与属性一样,区域的语义由其附加的操作定义,但是区域内部的块(如果有多个)可形成控制流图(CFG)。例如,图4中的affine.for操作是一个循环,其中位于({和})定界符之间的单独块是一个区域。 Op规定了跨区域的控制流。在这个例子中,重复执行主体直到达到上限。

每个区域的主体是一系列块,每个块以终止符(terminator)操作结尾,终止符操作可能具有后续块,控制流可以转移到后续块。每个终止符(例如“switch”,“conditional branch”或“unwind”)定义自己的语义。终止符可以选择将控制流转移到同一区域中的另一个块,或将其返回到包含该区域的Op。后续块的图定义了CFG,从而允许区域内有基于标准静态单赋值的控制流。

MLIR不使用φ节点,而是使用静态单赋值的函数形式。其中,终止符将值传给后继块定义的块参数(block arguments)。每个块都有一个(可能为空)类型化的块参数列表,这些参数是常规值并符合静态单赋值。终止符Op的语义定义了在控制权转移后该块的参数会采用的值。对于该区域的第一个(入口)块,值由包含Op的语义定义。例如,affine.for使用入口块参数%arg4作为循环归纳变量。

Value dominance and visibility

Ops只能使用范围内的值,即根据SSA支配、嵌套和包含操作的语义限制可见的值。如果值遵循标准的SSA支配关系,则在CFG中可以看到这些值,在这些值中,可以确保控件在使用前先经过定义。

基于区域的可见性是根据区域的简单嵌套来定义的:如果Op的操作数在当前区域之外,则必须按use的区域上方和外部词法对其进行定义,这允许affine.for操作中的Op使用外部作用域中定义的值。

MLIR还允许将操作定义为与上方隔离,表明该操作是范围障碍(scope barrier)。例如, “std.func” Op定义了一个函数,该函数内的操作不能引用该函数外定义的值。除了提供有用的语义检查之外,由于没有use-def链可以跨过隔离障碍,包含与上方隔离(isolated-from-above)的Ops的模块也可以由ML编译器并行处理。这对于利用多核计算机进行的编译很重要。

Symbols and symbol tables

Op可以附加一个符号表。这个符号表是将名称(以字符串表示)与IR对象(称为符号)相关联的标准方法。 IR没有规定符号的用途,而是交由Op定义。对于无需遵守静态单赋值规则的命名实体,符号很有用。符号不能在同一表中重复定义,但是可以在定义之前使用符号。例如,全局变量、函数或命名模块可以表示为符号。没有这种机制,就不可能定义递归函数(在定义中引用自己)。如果附带符号表的Op的关联区域包含相似的Op,那么符号表可以嵌套。 MLIR提供了一种机制来引用Op中的符号,包括嵌套符号。

Dialects

MLIR使用dialect管理可扩展性。Dialect在一个唯一的命名空间下提供Op、属性和类型的逻辑分组。Dialect本身并未引入任何新的语义,而是用作逻辑分组机制,并且可用于提供dialect通用Op支持(例如,dialect中所有op的常量折叠行为)。Dialect命名空间在操作码中是以“.”分隔的前缀,例如,图4使用的affine和std dialect。

概念上可以将Op、类型和属性分成dialect,这类似于设计一组模块化库。例如,某种dialect可以包含用于对硬件向量进行操作的Op和类型(例如,shuffle、插入/提取元素、掩码等),而另一种dialect可以包含用于对代数向量进行操作的Op和类型(例如,绝对值、点积等 )。两种dialect是否使用相同的向量类型以及该类型属于哪一个,可以由MLIR用户在设计时决定。

当然也可以将所有Op、类型和属性放在一个dialect中,但可以想见,这必然很快就会因为大量概念和名称冲突等问题,导致dialect变得难以管理。尽管每个Op、类型和属性都只属于一个dialect,但是MLIR明确支持多种dialect的混合以便能实现渐进式降级。来自不同dialect的Op可以在IR的任何级别共存,可以使用在不同dialect中定义的类型,等等。Dialect的混合可以加强重用性、可扩展性和灵活性。

类型系统(Type system )

MLIR中的每个值都有类型,该类型在产生值的Op或将值定义为参数的块中指定。类型为IR提供了编译期语义。 MLIR中的类型系统是用户可扩展的,并且可以引用已有外部类型系统(例如llvm::Type或clang::Type)。 MLIR强制执行严格的类型等价检查,并且不提供类型转换规则。Op使用类似尾函数的语法列出其输入和结果类型。图4中,std.load从内存引用和索引类型映射到加载的值的类型。

从类型理论的角度来看,MLIR仅支持非依赖类型,包括trivial类型、参数类型、函数类型、求和和乘积类型。尽管可以通过将Ops与符号和用户定义的类型结合起来,在CurryHoward同构的字面解释中实现从属类型系统,但这样的类型对于IR来说是不透明的。

标准类型

此外,MLIR提供了一组标准化的常用类型,包括任意精度整数、标准浮点类型和简单的通用容器,如元组(tuple)、多维矢量和张量。这些类型仅是方便dialect开发者,但是不要求一定使用。

函数和模块

与常规IR相似,MLIR通常被构造为函数和模块,这些不是MLIR的新概念概念。函数和模块在内置dialect中作为Op实现。模块是一个具有单独区域的Op,这个区域包含了单独块。模块被一个不转移控制流的dummy Op终止。模块定义了一个可以被引用的符号。像任何块一样,其主体包含一系列Op,这些Op可以是函数、全局变量、编译器元数据或其它顶级构造。

函数是具有单个区域的Op,其参数对应于函数参数。函数定义了一个可以按名称引用的符号。使用函数调用Op可以将控制流转移到函数中。一旦进入内部,控制流程将遵循区域中各个块的CFG。 “return”终止符没有后继,而是终止区域执行,从而将控制流转移回函数的调用方。 “return”终止符Op的任何操作数都是函数的返回值。

4. IR基础设施

除了IR本身之外,MLIR还提供了用于定义IR元素(如dialect、Op、 pattern rewrite、验证和可重用pass)的基础结构。 当定义新的抽象并将MLIR用作优化工具包时,MLIR的基础结构对于提供可扩展性和易用性至关重要。

4.1 操作描述

MLIR使用TableGen[47]规范定义操作说明(Operation Descriptions, ODS),以声明的方式定义Op的结构及其验证程序组件。 TableGen是一种在LLVM中广泛使用的数据建模工具,目的是帮助定义和维护领域特定( domain-specific)信息的记录。选择TableGen为Op和重写模式建模可以得到行业认可。 ODS可以看作是MLIR Op定义的DSL,MLIR Op定义又被嵌入到TableGen输入语言中。因此ODS语法由TableGen规定,但MLIR特定的语义由ODS规定。 ODS定义最终会转换为C++代码,这些代码可以与编译系统的其余部分互操作。

MLIR使用TableGen Op类在ODS中对Op进行建模。图5显示了Op ODS定义的示例。每个Op定义都有一个名称,该名称是一个唯一标识符。Op的特征(trait)列表描述了Op属性。Op的argument(参数)列表指定Op的操作数和属性。Op定义中还有一个result(结果)列表。Op的参数和结果具有名称和类型约束(例如float或int32的固定形状张量)。 Op定义还可以指定人类可读的Op描述。当Op定义需要比ODS更精细的控制时,可以通过builder、printer、parser、verifier语句注入其他C++代码。操作特征可以是通用的,例如““has no side-effects”,也可以是特定于dialect或ODS的,例如“has custom exporter”。 ODS中的特征可以由定义特征行为的C++类支持。MLIR没有固定的特征集合,但是有些特征对ODS是已知的(例如,“shape result and operand type”表示当完全捕获给定输入类型时,输出类型的约束),或对优化器是已知的(例如,“has no side-effects”,请参阅第6.1节)。

e23f35959995a2e12fbbf99b1ce54030.png

类型约束会检查参数/结果类型的属性,并且由用户/dialect扩展。 MLIR基础结构还提供了许多预定义的类型约束,例如“any type”、““tensor with element satisfying the given constraint”、““vector of given rank”等。ODS对自动推断操作数结果的返回类型的支持很有限,这些操作数使用了由特征带来的约束。更多信息请参见第4.2节。

4.2 Declarative rewrites

许多MLIR转换涉及Op操作,尽管某些转换需要对IR进行复杂的修改,但许多其它转换可以表示为对静态单赋值 use-def关系定义DAG的简单重写。 MLIR提供了一个图形重写框架,并辅以声明性重写规则(Declarative Rewrite Rule, DRR)系统,使模式表达变得简单。

与ODS相似,DRR是嵌入到TableGen语言中的DSL。 DRR表示源和目标DAG模式以及约束(包括动态约束[49])和模式优先级的收益。模式可以捕获和重用Op的参数。从概念上讲,DRR表示在特定约束下DAG的等效性。图6给出了DRR模式的示例,该模式将图5中定义的Op转换为由compare和select组成的通用低层实现。

9c6b872a80a251efeb8b0486f15b787b.png

DRR被转换为C++代码,可以使用通用图重写框架将其与直接在C++中定义的更复杂的模式混合。通过这项功能,MLIR可以使常见用例保持简洁,且不会限制框架的通用性。

4.3 Pass管理器

MLIR pass管理器以各种粒度组织并处理IR pass序列,保证pass的高效执行。现有编译系统中的pass管理通常是按照固定的粒度(例如,模块、函数或循环pass管理器)定义的。但在MLIR中,模块和功能并无特殊,只是具有区域的Op,并且有多种变体。因此,MLIR pass管理器也不专门针对固定的Op集合,而是针对任意嵌套级别的任意Op。

并行编译

MLIR的一个重要需求是可以利用多核计算机来加快编译速度。pass管理器支持并发遍历和修改中间表示,这可以通过操作的“与上方隔离(isolated-from-above)”属性提供的不变量来实现,因为静态单赋值 use-def链无法跨越这些op的区域边界,因此具有这种行为的操作(例如“ std.func”操作)定义了可以并行处理的区域树。

这个需求也是MLIR不具有整个模块use-def链的原因(这与LLVM相反)。全局对象通过符号表条目进行引用,而常量则由具有关联属性的操作实现。

4.4 可相互转换的IR文本表示形式

MLIR中的IR和操作具有文本表示形式,可以完全反映内存中的IR表示,这对于调试、理解转换期间的IR以及编写测试用例至关重要。图4所示的原始IR表示冗长且难以理解,因此MLIR允许用户为Op定义定制的打印和解析格式,这使得示例可以如图8所示进行打印和解析,这更容易使用。

两种形式可以完全相互转换,并且可以使用文本形式作为输入和输出,分别测试每个编译器pass。由于没有隐藏状态,因此运行单个pass的结果与在完整pass pipeline中运行相同pass的结果相同。这种方法对用户友好,因为可以手动创建IR格式,并可方便跟踪IR转换。

4.5 文档

Dialect、Op和接口都有从其对应ODS描述生成的文档。除了摘要和更易读懂的描述之外,生成的文档还包括参数和结果类型约束。由于验证代码和文档使用相同的来源,因此文档可以与运行时行为保持同步。

4.6 验证器

验证器用于增强IR和Ops不变式的结构正确性,允许pass假定已验证的IR不变式是经过检查的,并且还可以用作调试工具。验证过程以MLIR总体结构属性检查开始,比如,检查类型必须完全匹配,值仅定义一次且遵守支配规则和可见性,符号名称在符号表中是唯一的,所有块均以终结符Op结尾,等等。之后,应用各个Op和属性的验证器。每个Op可以定义一组检查结构和语义有效性规则。例如,二元Op会检查是否有两个操作数,许多Op只接受特定类型的值,而许多Op需要附加特定的属性或区域。同样,dialect属性只能在特定的Op上被允许使用,或者通过这些属性对其所附加的Op做进一步的限制。例如,dialect属性可以要求Op仅使用dialect中定义的类型,即使Op本身更通用。验证失败被视为invariant violation并中止编译。

5. MLIR的应用

MLIR系统的目的是概括和驱动各种不同类型的编译器项目,因此我们的主要评估指标是展示MLIR已被哪些项目采用。本节提供了用户社区活动的简介,并详细描述了一些用例,突出说明MLIR的通用性和可扩展性,并展示MLIR如何能很好地实现定制设计原则。

目前,MLIR还是一个不断发展的开源项目,其用户社区遍布学术界和行业。来自4个不同国家的4个国家实验室和16个大学的人士参加了在高性能计算(HPC)中使用MLIR的学术研讨会。 MLIR还得到了14家跨国公司的认可。在LLVM Developer Meeting上,超过100个业界开发人员参加了有关MLIR的圆桌会议。有超过26种dialect正在开发中,并且来自不同公司的7个项目正在用MLIR替换自定义编译器基础结构。这表明了对MLIR的真实需求,并认可了MLIR的可用性。

5.1 TensorFlow graphs

尽管大多数编译器开发人员也都熟悉其它表示形式,但是MLIR的关键用例之一是支持机器学习框架的开发。机器学习框架的内部表示通常基于具有动态执行语义的数据流图[53]。

TensorFlow[1]是这种框架的一个例子。TensorFlow的表示是高级数据流计算,其中的节点是可以放置在各种设备(包括特定的硬件加速器)上的各种计算过程。

TensorFlow使用MLIR对该内部表示进行建模,并针对图1所示的用例进行转换,将简单的代数优化转换为能在(硬件加速器的)数据中心集群上并行执行的、新形式的图,并将IR降级转换为能使用XLA[57]这类工具生成高效本地代码、适合移动部署的表示。 MLIR中的TensorFlow图表示如图7所示。

20ce4669b7ed4259396eea5f8ee8260e.png

2b6b6bf08dc8a5b548bd0d23d7424fa0.png

5.2 多面体代码生成(Polyhedral code generation)

MLIR的最初动机之一是探索加速器的多面体代码生成。affine dialect是简化的多面体表示形式,设计目的是实现渐进式IR降级。尽管对设计要点的全面探讨不在本文的讨论范围之内,本文还是说明了affine dialect的几个方面,以展示MLIR的建模能力,并将affine dialect与过去的一些表示形式进行了对比[17、19、54、55、52] 。

5.2.1 相似之处

MLIR affine dialect可以对所有内存访问的结构化多维类型做操作。在默认情况下,这些结构化类型是注入的(injective),保证不同的索引不会因构造而混叠,这是多面体依赖分析的常见前提。

Affine modeling可分为两个部分。属性用于在编译时对仿射图和整数集建模,而Op则用于对代码应用仿射约束。即,affine.for Op是一个“for”循环,其边界表示为值的仿射图,并且这些值要求在函数中保持不变。因此,循环具有静态控制流。与此类似,affine.if是受仿射整数集限制的条件语句。循环和条件语句的主体是区域,这些区域使用affine.load和affine.store将索引限制为循环迭代器的仿射形式。这样可以进行精确的仿射依赖分析,同时避免了从低级表示中推断仿射形式。

5.2.2 不同之处

MLIR与现有多面体代码生成框架之间的差异很多,可以将其分为以下四类:

(1)丰富的类型:MLIR结构化的内存引用类型包含了一个将缓冲区索引空间连接到实际地址空间的布局图。这两种空间的分隔可以改善循环和数据转换的组合,因为对数据布局的修改不会影响到代码,也不会污染依赖关系分析。文献[38]已经探讨过这种转换混合,但并不常见。

(2)抽象的混合:MLIR中的仿射循环体可以通过对类型化(typed )静态单赋值的操作来表示。因此,所有传统的编译器分析和转换过程仍然适用,并且可以与多面体转换交错使用。相反,多面体编译器经常将这些细节完全抽象掉,这使得多面体编译器难以操做某些对象,例如向量类型。

(3)较小的表示差异:多面体模型的主要特征之一是能够表示类型系统中循环迭代的顺序。但是,多面体转换会将IR提升为与原始IR完全不同的表示形式[20,10]。此外,从变换后的多面体到循环的转换在计算上很困难[7]。基于MLIR的表示在低级表示中保持了高级循环结构,因而不再需要提升IR。

(4)如第4.3节所述,编译速度是MLIR的关键目标,但现有大多数多面体方法并不关注编译速度。这些多面体方法严重依赖指数复杂度的算法,依赖整数线性编程自动推导出循环顺序,依赖多面体扫描算法将IR转换回循环。 MLIR采用的方法不依赖多面体扫描,因为循环保留在IR中。

Affine dialect的经验表明,MLIR可用于多种代码生成项目,并且其开发过程有助于使MLIR设计更实用。

5.3 Fortran IR (FIR) 略

5.4 领域特定编译器(Domain-specific compiler)

上述MLIR应用集中在大型编译工作流程中,但是MLIR也可以用于为特定的小型工作流程构建领域特定编译器。可重用、模块化基础架构使这些专门的应用变得可行,而且构建成本相对较低。

优化MLIR模式重写

如第4节所述,MLIR具有可用于模式重写的可扩展系统。除了静态声明的模式之外,还有一些应用要求重写模式能在运行时动态扩展,允许硬件供应商在驱动程序中添加新的降级过程。解决方案是将MLIR模式重写表示为MLIR dialect,这样就能够使用MLIR基础结构来动态构建和优化有限状态机(FSM)匹配器和重写器。这项工作包括在其它系统中的FSM优化,例如LLVM SelectionDAG和GlobalISel指令选择系统。

Lattice regression compiler

Lattice regression[18]是一种以快速评估时间和可解释性而闻名的机器学习技术,其编译器的前身是使用C++模板实现的,这使其能够通过元编程实现高性能代码,但是在端到端模型上表达常规优化并不容易。这种特殊的Lattice regression系统可能用在数百万用户的应用中,因此,提高性能至关重要。

MLIR被用作该专业领域的新编译器的基础,该编译器由专门的搜索方法驱动,这样可以在编译过程中解决机器学习问题。新编译器的开发只投入了3个人月,就使生产模型性能提高了8倍,同时还提高了编译期的透明度。

6. MLIR设计的成果

MLIR设计有助于对新语言和编译抽象进行建模,同时有助于重用现有的、通用的相关编译方法。MLIR对很多问题的有效解决方法是“添加新操作、新类型”,如果可能,将其收集到“某个新dialect”中。对于编译器工程而言,这是重大的设计转变,产生了新的机遇,挑战和见解。本节将探讨其中部分观点。

6.1 可重用编译器pass

如果一种IR语言有表示多个抽象级别的能力,那么会自然而然地带来一个需求,即,编写可在多个抽象级别工作的pass。关于MLIR的一个常见问题是,既然MLIR具有可扩展的操作和类型系统,那么如何编写编译器pass?虽然编译器pass可能总是以保守、正确的方式处理未知结构,但MLIR的目标是生成高性能代码,主要有四种方法:

基本操作特征

一些“bread and butter”编译器pass(如“死代码消除”和“通用子表达式消除”)依赖于定义为Op特征的简单属性(例如“has no side effect”或“is commutative”)。ODS中操作的定义允许操作的开发者指定这些特征,并且pass可以使用此信息来保持操作在许多不同抽象域都适用。

MLIR的可扩展性体现为某些结构属性,其中包括下述信息:是否知道某个操作是控制流终止符,是否知道某个操作包含的区域是与上方隔离的(isolated-from-above)等等。这些信息可用于函数、闭包、模块和其他代码结构的建模和处理。

Privileged operation hooks

虽然某些特征可以用单比特建模,但是其它很多特征则需要C++代码实现,例如常量折叠逻辑。 MLIR对适用于大量pass的某些hook提供了最好的支持。这些hook可以基于每个操作实现,也可以在dialect对象中实现。后一种方法对支持诸如TensorFlow ops的常量折叠之类pass很方便,在这种情况下,很容易实现对现有逻辑的委托。

尽管常量折叠是非常重要的功能,但更有意思的hook是getCanonicalizationPatterns,这个hook允许指定应用于操作的折叠模式。这使得重要的代数简化形式(例如x − x→0,min(x,y,y)→min(x,y)等)具有可扩展性,并可帮助将普通“规范化(Canonicalization)”pass应用到所有dialect 。这些都使得单一的可扩展系统可以包含像“InstCombine”、“DAGCombine”、“PeepholeOptimizer”、“SILCombine”这类pass,以及LLVM生态系统(和其它编译器)中的其它特殊用途pass。

优化接口

MLIR的主要目标是可扩展性,不仅在操作和类型方面,而且在转换方面也要有可扩展性。虽然规范化(canonicalization)和常量折叠是关键操作,但仍需要以某些方式对许多标准转换进行参数化设置,才能描述转换的特定属性,才能实现成本模型等。

问题的解决方案是称为“优化接口”的子系统。考虑一下MLIR内联pass,我们希望inliner可以处理TensorFlow图、Flang函数、函数语言的闭包等,但是inliner不知道调用方是什么,甚至不知道被调用方是什么。inliner需要了解的核心特性是:

  • 将操作内联到给定区域是否有效;
  • 如何处理内联后终止于块中间的终止符操作。

为了了解这些属性,Inliner pass定义了图10中的接口。各个操作和dialect可以向MLIR注册该接口在操作和dialect中的实现,并从通用Innerer pass中获益。如果某个操作或dialect无法提供接口,则相应的优化pass将会保守地对待该操作。这种设计让dialect的开发者能快速启动开发并运行dialect。随着时间的推移,通过将更多的精力投入接口开发,可以从系统中获得更多收益。

50902977aadf0d8fd903d2fa6e33d11a.png

优化接口还为核心编译器提供了模块化优势,因为dialect特定的逻辑是在dialect自身内部实现,而不是在核心转换中实现。

Dialect特定pass

最后,定义特定dialect可以定义专用pass,MLIR系统中的这些pass和在其它编译器系统中的pass一样都很有用。比如说,如果想让代码生成器根据特定的机器约束对机器指令进行自定义调度,就可以通过专用pass达到目的。这可当作开发新转换pass的起点,不需要考虑pass的通用性。

6.2 Dialect的混合

MLIR中一个最根本(也是最难理解)的部分是允许并鼓励将来自不同dialect的操作混合在一个程序中。尽管在某些情况下(例如,将主机和加速器计算保存在同一模块中),这样做很容易理解,但最有趣的情况是,在MLIR中可以将dialect直接混合(因为这样可以实现整个类的重用),这在其它系统中是见不到的。

考虑第5.2节中描述的affine dialect。仿射控制流和仿射映射的定义与仿射区域中包含的操作的语义无关。在我们的案例中,我们将affine dialect与“标准”dialect结合起来,以目标无关的形式(如同LLVM IR)表示简单算术,也可以针对内部加速器,将affine dialect与多个目标相关机器指令dialect结合。也有人将affine dialect与其它问题领域的抽象相结合。

重用通用多面体转换(使用Op接口获取特定转换中操作的语义)的能力是分解编译器基础结构的一种有力方法。另一个例子是,可以在各种源语言IR中使用和重用OpenMP dialect。

6.3 互操作性

本文的工作涉及与大量现有系统的互操作,例如,protobuff格式的机器学习图、包括LLVM IR在内的编译器IR、各种专有指令集等。任何一种表示形式不可避免都有各种缺陷,虽然这些缺陷在某个现有系统的适用场景下是合理的,但是MLIR的表达能力使MLIR成为一种更好的表示形式。因为importer和exporters的测试难度很大(测试用例通常是二进制格式),因此我们希望确保其复杂性最低。

问题的解决方案是尽可能定义与外部系统直接相对应的dialect,从而能以一种简单且可预测的方式来回转换该格式。一旦将IR导入MLIR格式中,就可以使用MLIR基础结构中所有转换,将导入的IR升级或降级为某种更适合的IR格式,并允许对这些转换pass进行类似于所有其它MLIR pass的测试。

这类dialect的例子很多,包括:a)LLVM dialect,可将LLVM IR映射为MLIR; b)TensorFlow的图表示形式,提出这种表示是为了简化TensorFlow中“切换和合并(switch and merge)”节点相关的分析和转换;c )函数式控制流运算符。“functional while”和“functional if”在机器学习图中很常见,在这种情况下,将其代码主体作为区域而不是外联(out-of-line)函数更方便。

这种方法对我们来说效果很好,并且MLIR工具对于编写外来二进制文件格式的测试用例也很有用。

6.4 非标准化设计带来了新挑战

虽然MLIR允许开发者定义几乎任意的抽象,但MLIR也几乎没有提供相关指导,比如,在实践中哪种方法效果更好或更差?现在,一些工程师和研究人员已经有这方面的经验,并且已经意识到,编译器IR设计和抽象设计的“艺术”在编译器和语言领域并未得到很好的理解。许多人在已建立系统的约束下工作,但是相对而言,很少人有机会自己定义抽象。

这是一个挑战,但也是未来研究的机遇。MLIR社区正在通过这些抽象设计积累专业知识,随着时间的推移,这将是一个硕果累累的研究领域。

6.5 期望

在构建并将MLIR应用于许多不同的系统之后,可以发现MLIR的设计与其它编译器基础结构有很大不同。我们相信仍有很多应用领域有待发现,完全理解MLIR所有设计要点并建立最佳实践,需要更多的研究时间。例如,out-of-tree dialect的兴起、使用MLIR的源语言前端数量的增加、在抽象语法树上的可能应用,以及对结构化数据(如JSON,协议缓冲区等)的应用,这些都还处于很早期,可能会从中发现许多有趣的新挑战和机遇。

7. 相关工作

MLIR是一个涵盖多个不同领域的项目。虽然其基础设施提供了一个新的系统,但组成基础设施的各个组件在相关文献中都已有类似模块。与IR设计本身直接相关的参考和讨论,请参阅第2节。

MLIR是类似于LLVM[25]的编译器基础结构,但LLVM在标量优化和同构编译做得很好,而MLIR的目标是将各种数据结构和算法建模为一流的数据值和操作,包括张量代数和算法、图表示以及异构编译。 MLIR允许混合匹配(mix-and-match)优化,允许将编译pass分解为组件并重新定义降级。这在很大程度上归功于模式重写基础结构,将完整转换捕获为局部模式的组合,并控制在单个操作粒度上应用什么模式重写。重写逻辑扩展、形式化和验证将是下一步的重要工作[9,27]。在后端方面,MLIR DDR有一个类似于LLVM指令选择的基础结构,支持以多结果模式和规格为约束的可扩展操作[49]。

许多编程语言和模型都解决了硬件异构问题。 同构编程模型OpenMP基于StarSs和OpenACC[34,31]等较早的建议,增加了对卸载(offloading)任务和加速器并行区域[32]的支持。 C++ AMP、HCC和SyCL利用传统的Clang/LLVM流程和C++为硬件加速提供高级抽象[46]。但是,所有这些例子都依赖于宿主语言(通常为C++)中的已有优化来减轻抽象造成的损失,从而将高级构造快速降级到对运行时执行环境的调用。针对异构编译过程本身的研究工作很少。扩展LLVM IR的并行中间表示解决了部分问题,但传统上仍将重点放在同构环境上[23,42]。迄今为止,最有雄心的工作可能是Liquid Metal[3],其中提供了协同设计的领域特定语言(DSL),以及将被管理对象的语义转换为静态的、向量的或可重配置硬件的编译流程。然而,在其Lime编译器中,大部分工作量都放在将round对象装配到square硬件中(Kou和Palsberg [24])。 MLIR通过可扩展的操作和类型集合,为包含异构特性的高级语言提供直接嵌入手段,同时提供了一个通用基础结构,可逐步降级这些结构,并最大程度地在不同目标之间重用通用组件。

解决语言异构性已成为元编程系统,尤其是多阶段编程的长期目标。Lightweight Modular Staging(LMS)[39]是最新的技术框架和运行时代码生成器,提供了可生成高效代码并将DSL嵌入Scala的核心组件库。 Delite[45]声称可以大幅提高DSL开发者的效率,同时支持并行和异构执行。我们认为这种方法是对MLIR的补充,为嵌入DSL提供了更高层次的抽象,并通过通用元编程构造实现了优化。

说到语言语法,ANTLR[33]是解析器生成器中,一种能简化新编译器前端开发的技术。 MLIR目前还没有通用的解析器代码生成模块,没有AST构造或建模功能。将MLIR与ANTLR之类的系统结合使用,可以得到从用户输入到代码生成的可重用编译器库。

XLA[57]、Glow[40]和TVM[11]通过在机器学习中的应用,解决类似的异构编译目标。但是这些技术都是很具体的代码生成实例,从图形抽象开始,针对的是加速器的多维矢量抽象。这些技术都可以将MLIR用作基础架构,在使用各自现有的代码生成策略的同时,充分利用MLIR的通用功能。同样,来自Halide[36]和TVM的循环嵌套元编程技术,较早的循环嵌套元编程文献[19,41,5,14],和全自动流程,如PolyMage[28]、Tensor Com-Phenhension[52]、Stripe[58]、Diesel[16]、Tiramisu[4]及其底层多面体编译技术[17,54,8,55],可以在基于MLIR的编译框架中以不同的代码生成路径共存。序列化和互操作性格式有不同的方法解决ML前端的多样性问题,例如,ONNX[48]的方法是通过提供不同框架都可以映射的通用op集合。ONNX会成为MLIR的一种dialect选择,其他op可以被降级为该dialect。

8.结论和未来的工作

本文介绍了MLIR,可用作编译器构造的灵活且可扩展的基础结构。本文描述了MLIR的具体设计,展示了其在一系列重要领域中的适用性,并描述了许多原创研究和工程意义。

展望未来,我们希望看到编译器社区(例如Clang C和C ++编译器)和不同领域的专家如何能从更高级的、语言特定IR中受益。我们也想知道,MLIR是否能为教授编译器和IR设计技术提供新的方法,并希望看到这种基础设施加速新领域的研究。

未来的方向

MLIR有多个未来的方向。在ML和HPC领域,包括从具有符号形状(symbolic shapes)的秩多态

(rank-polymorphic)规范中推断出高效的Op实现。其中还涉及实现更广泛的数据结构(稀疏、图形)和程序转换,以及将符号推理(例如自动微分和算法简化)与更常规的数据流及基于控制流优化的结合。除ML和HPC之外,开发者还可以考虑MLIR在其它相关领域的适用性,例如安全编译、安全关键(safety-critical )系统、数据分析和图形处理,关系查询优化等。

回到通用语言领域,一个明显缺失的前端是从Clang派生的C++中级表示形式。比如说,“CIL”在本质上与Swift的SIL和Rust的MIR相似,这将有助于优化常见的C++惯用法,这些惯用法目前需要从降级的代码中重建(例如,将std :: vector视为数组而不是指针操作)。支持垃圾收集的语言、在MLIR中支持类型推断的高阶和多态类型系统也是未解决的挑战。

在LLVM中探索并行性和并发构造非常困难,主要是因为所需的修改是侵入性的,且不容易分层(例如,注入元数据并检查所有pass以确保元数据被传播,同时由于抽象级别太低而失去优化机会)。使用MLIR,并行构造可以由最优操作实现,可以使用区域和并行惯用法特定的验证。在降级到(比如说)LLVM(LLVM可以对已经降级的代码执行常规转换)之前,MLIR的实现方式可以支持更高级别的转换。

除了调试和测试之外,IR的文本形式对于教学也很有用。通过工具展示高性能编译中优化过程的交互信息有助于新生手了解编译器。 IR设计是开发新的编译器或优化框架不可或缺的部分,但是许多本科生的编译器课程并未涵盖IR设计。 MLIR为此类课程提供了新的探索方法。

参考文献 略

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值