LLVM 3.0 类型系统重写
LLVM 3.0 中最普遍的 IR(以及编译器 API)更改之一是对 LLVM IR 类型系统的完整重新实现。此更改早就应该进行(原始类型系统从 LLVM 1.0 开始!),它使编译器速度更快,极大地简化了 VMCore 的关键子系统,并消除了 IR 中一些经常令人困惑和不便的设计点。这篇文章解释了更改的原因以及新系统的工作原理。类型系统的目标
The LLVM IR 类型系统 是 IR 中一个相当直接的部分。类型系统由三个主要部分组成:基本类型(如 'double' 和整型类型)、派生类型(如结构体、数组和向量)以及处理类型前向声明的机制('opaque')。类型系统有一些重要的需求限制了它的设计:我们希望能够使用有效的指针相等性检查来确定结构类型相等性,我们希望允许类型延迟细化(例如在链接时,一个模块应该能够完成另一个模块中的类型),我们希望它易于表达许多不同的源语言,并且我们希望一个简单且可预测的系统。
IR 类型系统中唯一真正困难的部分是处理类型的转发声明。要了解这一点,请观察到类型系统实际上是由一个复杂的类型图表示的(该图通常是循环的)。例如,一个简单的整数单向链表可能像这样声明
在这种情况下,类型图包括一个指向 IntegerType 和 PointerType 的 StructType。PointerType 指回 StructType。在许多实际程序中,图形是复杂的并且高度循环的,特别是对于 C++ 应用程序而言。
%intlist = type { %intlist*, i32 }
有了这个背景,让我们先谈谈 LLVM 2.9 及更早版本是如何工作的。
旧的类型系统
The LLVM 2.9 类型系统具有所有简单明了的部件,并使用 OpaqueType 的实例来表示类型的前向声明。当此类型稍后解析(例如在链接时)时,一个称为“类型细化”的过程更新指向旧 OpaqueType 的所有指针以指向新的定义,在运行时变异类型图,然后删除原始 OpaqueType。例如,如果一个模块包含那么 T2 是指向 OpaqueType 的 PointerType。如果我们将 %T1 解析为 {}(一个空结构体),那么 %T2 变异为指向空 StructType 的 PointerType。有关此内容的更多信息,请参见 LLVM 2.9 程序员手册中的类型解析部分。
%T1 = type opaque
%T2 = type %T1*
变异和移动类型的后果
不幸的是,尽管从概念上讲很简单,但这种类型系统存在几个问题。为了保证指针相等性检查对结构类型等效性检查有效,VMCore 必须在类型解析期间每次变异类型时重新对其进行唯一化。这看起来可能不是什么大问题,但一次类型细化会导致数百种其他类型被变异,并且细化数百或数千种类型非常普遍(例如,在为 LTO 链接应用程序时)。这种性能是不可接受的,特别是因为唯一化循环图需要进行完整的图形同构检查,而我们之前的实现算法效率不高。另一个问题是,不仅仅是类型需要更新:包含指向类型的指针的任何内容都必须更新,否则就会出现悬空指针。这个问题以多种方式表现出来:例如,每个 Value 都有一个指向类型的指针。为了使系统更高效,Value::getType() 实际上执行了一个延迟的“并查集”步骤,以确保它始终返回一个规范化和唯一的类型。这使得 Value::getType()(一个非常常见的调用)比它应该的更昂贵。
这种“类型更新”问题造成的更严重的问题是,当您通过 LLVM API 操作和构建 IR 时。由于类型可以移动,因此很容易获得悬空指针,从而导致很多混乱和很多 LLVM API 的损坏客户端。这还因为构建 简单递归类型 需要使用类型细化。我们尝试用 PATypeHolder 和 PATypeHandle 类来简化这一点,但它们只有在你正确使用它们时才有效,而且通常人们对它们了解不多。
类型唯一化带来的意外行为
许多 LLVM 客户端,一旦他们的程序正常运行,就会很快发现类型唯一化一个令人惊讶的方面:类型名称不是类型系统的一部分,而是一个“旁侧”数据结构。在进行类型唯一化时,不会考虑名称,同一个类型可以有多个名称(这会导致很多混乱)。例如,考虑如果 %T1 稍后解析为 {},那么 %T1 和 %T2 将都成为同一个空结构类型名称,以前称为“%T1*”的类型将与以前称为“%T2*”的类型统一,现在 IR 将打印为
%T1 = type opaque
@G1 = external global %T1*
%T2 = type {}
@G2 = external global %T2*
... 请注意,G2 现在具有类型“%T1*”!这是因为类型系统中的名称只是一个旁侧哈希表,因此 asmprinter 会在打印时从大量类型名称中任意选择一个。这是“正确的”,但对于那些不了解类型系统内部的人来说非常令人困惑,而且没有帮助。这也使得从 C++ 编译器读取 .ll 导出文件非常困难,因为在结构上相同的类型中使用不同的名称非常普遍。
%T1 = type {}
@G1 = external global %T1*
%T2 = type {}
@G2 = external global %T1*
类型上引用
最后一个问题(我不想过多讨论)是,我们以前可能存在这样的情况:一个类型根本没有名称。虽然从类型系统图的角度来看这是可以的,但如果它们是循环的并且没有名称,这会使类型打印变得不可能。这个问题的解决方法是称为 类型上引用 的系统。类型上引用是一个优雅的解决方案,它允许 asmprinter(和解析器)能够在有限的空间内表示任意的递归类型,而不需要名称。例如,上面的 %intlist 示例可以表示为“{\2*, i32}”。它还允许构建一些不错(但令人惊讶)的类型,比如“\1*”,它是一个指向自身的指针!
尽管具有一定的美感和优雅,但大多数人并不理解类型上引用,而且它们也引起了很多混乱。能够从 LLVM IR 模块中剥离名称很重要(例如 -strip 传递),但也同样重要的是让编译器黑客能够理解这个系统!
为新的类型系统做准备
有了所有这些问题,我意识到 LLVM 需要一个更新更简单的类型系统。但是,新版本的 LLVM 也必须能够读取旧的 .bc 和 .ll 文件。为了实现这一点,大型重写被精心分阶段进行:LLVM 2.9 asmprinter 被增强为将不透明类型作为编号类型发出,而不是使用上引用。因此,LLVM 2.9 不是使用上引用发出 %intlist 示例,而是将其发出为LLVM 3.0 的计划是放弃与 LLVM 2.8(及更早版本)文件的兼容性,因此这使得 LLVM 3.0 中所需的“升级”逻辑变得简单很多。
%0 = type { %0*, i32 }
LLVM 3.0 类型系统
对于大多数客户端而言,LLVM 3.0 中的新类型系统看起来和闻起来与 2.9 类型系统非常相似。例如,由 LLVM 2.9 生成的 .bc 文件和 .ll 文件在被位代码读取器和 .ll 解析器读取时是可以读取的,并且会自动升级到 3.0 功能(尽管 LLVM 3.1 将放弃与 2.9 的兼容性)。这是因为类型系统保留了几乎所有之前的内容:基本类型和派生类型相同,只有 OpaqueType 被删除,StructType 被增强。简而言之,LLVM 3.0 不是基于细化的类型系统,在该系统中类型在内存中变异(需要重新唯一化以及指针的移动/更新),而是使用了类似于 C 中使用的类型系统,该系统基于类型完成。基本上,您现在不是创建不透明类型然后稍后替换它,而是创建一个没有主体结构的 StructType,然后稍后指定主体。要创建 %intlist 类型,您现在可以这样写
... 这是简单明了的,比 2.9 的方法 好得多。不过,这种设计有一些不明显的影响。
StructType *IntList = StructType::create(SomeLLVMContext, "intlist");
Type *Elts[] = { PointerType::getUnqual(IntList), Int32Type };
IntList->setBody(Elts);
只有结构类型可以是递归的
在之前的类型系统中,OpaqueType 可以解析为任何任意类型,允许诸如“%t1 = type %t1*”之类的奇异类型,它是一个指向自身的指针。在新类型系统中,只有 IR 结构类型可以缺少主体,因此不可能创建不涉及结构体的递归类型。字面结构体和已标识结构体
在新类型系统中,实际上存在两种不同的结构类型:“字面”结构体(例如“{i32, i32}”)和“已标识”结构体(例如“%ty = type {i32, i32}”)。已标识结构体是我们正在讨论的类型:它们可以有名称,并且可以在创建类型后指定其主体。已标识结构体不会与其他结构类型进行唯一化,这就是它们使用 StructType::create(...) 创建的原因。由于已标识类型可能是递归的,因此 asmprinter 始终按其名称(或一个数字,比如 %42,如果已标识结构体没有名称)打印它们。
字面结构类型与旧的 IR 结构类型类似:它们永远没有名称,并且通过结构标识进行唯一化:这意味着它们必须在构建时提供主体元素,而且它们永远不能是递归的。当被 asmprinter 打印时,它们总是内联打印,没有名称。字面结构类型由 StructType::get(...) 方法创建,这反映了它们是唯一的(调用可能实际分配一个新的 StructType,也可能不分配)。
我们预计已标识结构类型将是最常见的类型,并且前端只会生成字面结构类型以用于特殊情况。例如,对于元组、复数和其他简单情况,使用字面结构类型是合理的,在这些情况下,名称是任意的,而且会使 IR 更难阅读。优化器对此并不关心,因此如果您是前端的作者,请使用您在 IR 导出中想看到的任何内容。
已标识结构体与名称一一对应
以前,类型名称保存在一个“旁侧”哈希表中,而现在它们是类型本身的内在组成部分,并且唯一可以命名的类型是已标识结构体。这意味着 LLVM 3.0 不会表现出以前令人困惑的行为,即两个看似不同的结构体将以相同的名称打印。从模块中剥离类型名称时,已标识结构体只会变成匿名:它们仍然是“已标识的”,但没有名称。与 LLVM IR 中的其他匿名实体一样,它们以数字形式在 asmprinter 中打印。结构体名称在 LLVMContext 级别上唯一化
由于 `StructType::create` 总是返回一个新的标识类型,因此当您尝试创建两个具有相同名称的类型时,我们需要一些行为来处理这种情况。解决方案是 VMCore 检测到冲突并通过在类型名后面添加后缀来自动重命名后来的请求:当您请求一个名为 "foo" 的类型时,您实际上可能会得到一个名为 "foo.42" 的类型。这与其他 IR 对象(如指令和函数)一致,并且名称在 LLVMContext 级别上是唯一的。链接器 "链接" 类型并重新类型化 IR 对象。
此设计的有趣方面在于它使 IR 链接器的任务更加复杂。考虑将这两个 IR 模块链接在一起时会发生什么。链接器首先将这两个模块加载到同一个 LLVMContext 中。由于两个名为 "A" 的类型必须是不同的类型,并且由于只能存在一个名为 %A 的类型,因此我们实际上在内存中获得了这两个模块。
x.ll:
%A = type { i32 }
@G = external global %A
y.ll:
%A = type { i32 }
@G = global %A zeroinitializer
…现在很明显,@G 对象具有不同的类型。在链接这两个全局变量时,现在由链接器负责将 IR 对象的类型重新映射到一致的类型集,并将它们重写为一致的状态。这要求链接器计算相同类型的集合,并解决 VMCore 以前使用的相同图同构问题(如果您有兴趣,请参阅 lib/Linker/LinkModules.cpp 中的 "remapType" 逻辑)。
x.ll module:
%A = type { i32 }
@G = external global %A
y.ll module:
%A.1 = type { i32 }
@G = global %A.1 zeroinitializer
将此逻辑放在 IR 链接器中而不是 VMCore 中,在很多方面都比之前的设计更好:现在,合并和唯一化的成本只由 IR 链接器承担,而不是由所有位码读取和其他 IR 创建代码承担。代码更容易理解和算法优化,因为我们正在一次合并两个完整的图——而不是逐个解析类型。最后,这通过将一些复杂的逻辑从 VMCore 中移除来缩减了 VMCore 的大小。
在优化器(或更晚)中识别神奇的 IR 类型
与 LLVM 2.9 中一样,类型名称实际上并不是为在 IR 中用作语义信息而设计的:我们期望即使使用 -strip 选项从 IR 中删除所有无关名称,所有内容都能继续工作。但是,出于研究和其他目的,有时使用类型名称将信息从前端传播到 LLVM IR 可能会是一个方便的黑客。这将在 LLVM 3.0 中可靠地工作(只要您不运行 strip 选项或类似操作),因为标识类型不会被唯一化。但是,请注意可能会添加后缀,并将您的代码编写为容忍它。在优化器(或前端运行后的其他位置)中识别特定类型的更健壮方法是使用命名元数据节点查找类型。例如,如果您要查找 %foo 类型,您可以生成如下所示的 IR
然后,要查找 "foo" 类型,您只需查找 "magic.types" 命名元数据,并获取第一个元素的类型。即使类型名称被剥离或类型被自动重命名,第一个元素的类型也始终是正确且稳定的。
%foo = type { ... }
...
!magic.types = !{ %foo zeroinitializer }
命名元数据似乎存在一些混淆:与指令级元数据不同,它们不会被优化器传递丢弃或使之无效(只要它们不指向被优化器修改的函数或其他 IR 对象)。一般来说,命名元数据是将信息从前端传递到优化器或后端的一种比尝试使用类型名称玩游戏好得多、多得多、多得多的方法。