LLVM 不透明指针支持已落地
Crystal 1.8 将首次支持 LLVM 的不透明指针,从而允许编译器使用 LLVM 15 或更高版本构建。此外,此更新还显著提高了编译速度。
LLVM 中的指针
为了理解不透明指针的重要性,让我们看一个简单的示例程序
# test.cr
class Foo
def initialize(@x : Int32)
end
end
Foo.new(1)
使用 crystal build --prelude=empty --no-debug --emit=llvm-ir test.cr
构建上面的程序。编译器将创建一个包含程序 LLVM IR 的文件 test.ll
,LLVM 使用这种平台无关的中间表示来生成 LLVM 字节码,最终生成机器码。以下是对应于 Foo#initialize
的 LLVM 函数
; Function Attrs: uwtable
define internal i32 @"*Foo#initialize<Int32>:Int32"(%Foo* %self, i32 %x) #0 {
entry:
%0 = getelementptr inbounds %Foo, %Foo* %self, i32 0, i32 1
store i32 %x, i32* %0, align 4
ret i32 %x
}
虽然我们没有深入了解 Crystal 如何将方法编译成此 LLVM 函数(尽管我们确实在过去有关于此的 文章),但我们知道它
- 将
%self
参数(正在构造的Foo
对象)和来自Foo#initialize
的%x
参数作为参数; - 将
Foo
对象的@x
实例变量的地址分配给局部变量%0
; - 通过
%0
将%x
参数存储到@x
中; - 将
%x
返回给调用者。(这个调用者通常是Foo.new
,所以它在大多数情况下都没有使用。)
我们可以看到,%self
和 %0
的类型分别是 %Foo*
和 i32*
。这些是类型化指针,LLVM 一直在使用它们。在 LLVM 中,不同指向类型类型的指针必须使用 bitcast
LLVM 指令显式转换,否则生成的 LLVM IR 将是格式错误的。随着越来越多的 LLVM 前端出现,人们很快意识到类型化指针并没有提供多少有用的语义,反而在 IR 生成和分析方面增加了不必要的复杂性。
不透明指针
不透明指针 最早是在 2015 年 2 月 提出的,其中所有指针类型都将使用 LLVM IR 中的单个 ptr
来表示。然后在 2022 年 9 月,LLVM 15 现在默认使用不透明指针,类型化指针将在不久后被移除。如果我们再次编译上面的程序,但这次使用使用 LLVM 15 构建的 Crystal 编译器,我们可以看到不透明指针的作用
; Function Attrs: uwtable(sync)
define internal i32 @"*Foo#initialize<Int32>:Int32"(ptr %self, i32 %x) #0 {
entry:
%0 = getelementptr inbounds %Foo, ptr %self, i32 0, i32 1
store i32 %x, ptr %0, align 4
ret i32 %x
}
为了实现这一点,需要根据使用类型化指针还是不透明指针来构建一些 LLVM 指令,而 getelementptr
指令就是一个例子。以前从给定的指针推断出对象类型,但现在不透明指针不包含该信息,因此 IR 生成器(我们的 Crystal 编译器)需要单独提供此对象类型。在本例中,@x
实例变量和 #initialize
方法都属于 Foo
,因此 Crystal 知道将 Foo
传递给 getelementptr
。但是,该指令也用于编译器中的其他几十个地方,没有适用于所有情况的迁移方法。
Crystal 编译器更新
迁移到不透明指针的工作始于 2022 年 10 月,即 LLVM 15 发布后的一个月,在无数次段错误和规范失败之后,Crystal 现在在 master 分支上支持 LLVM 15。由于这是一项相当巨大的工作,我们当然希望不透明指针能够实现 LLVM 所承诺的性能优势。因此,这里提供了一些在 Apple M2 上重新构建编译器本身时收集的数字,首先使用 LLVM 14 编译器,然后使用 LLVM 15 编译器
- 非发布版本
- 代码生成 (crystal):2.65 秒 → 2.84 秒
- 代码生成 (bc+obj):5.91 秒 → 5.20 秒
- 代码生成 (链接):0.76 秒 → 0.34 秒
- dsymutil:0.35 秒 → 0.39 秒
- 发布版本
- 代码生成 (bc+obj):247.86 秒 → 184.37 秒
- 代码生成 (链接):0.45 秒 → 0.33 秒
- dsymutil:0.63 秒 → 0.52 秒
如果我们只考虑最后 3 个阶段(它们完全在 LLVM 的控制之下),那么非发布版本的提速为 18%,发布版本的提速为 34%!开发者报告了类似的数字,他们迫不及待地想用 LLVM 15 重新构建 Crystal。虽然为像 Crystal 本身这样的大型程序生成 LLVM IR 平均需要多花 0.2 秒,但 LLVM 的改进远远超过了它。这次迁移工作绝对值得。
这对我有何影响?
Crystal 的 夜间构建 已经在使用使用 LLVM 15 构建的编译器,并随时可以试用。1.8 将是第一个使用 LLVM 15 构建的稳定版本。这些编译器使用不透明指针,并在代码生成时间方面有所改进。使用 LLVM 14 及更低版本的编译器将继续使用类型化指针。
如果您的 Crystal 项目直接使用 stdlib 的 LLVM API,则需要注意一些弃用。否则,此更改不会以任何方式影响 Crystal 程序。它只是加速了编译器。
另一方面,如果您确实使用 Crystal 使用 Crystal 的 LLVM
API 来构建其他 LLVM 前端,请注意,作为迁移的一部分,Crystal 将不再支持低于 8.0 的 LLVM 版本,因为 8.0 是 LLVM 首次接受受不透明指针影响的指令(如上面的 getelementptr
)的单独类型的版本。 所有依赖于类型化指针的功能都被弃用,无论是否实际使用 LLVM 15。以下是受影响方法的完整列表
LLVM::Type#element_type
:在 LLVM 15 或更高版本上,在指针类型上调用此方法会引发异常。LLVM::Function#function_type
、#return_type
、#varargs?
这些方法没有快速的迁移方法。但是,如果函数是通过LLVM::FunctionCollection#add
构造的,则该方法现在有额外的重载,可以直接接受 LLVM 函数类型。这允许您使用LLVM::Type.function
并将类型存储在其他地方,然后再构造函数。LLVM::Builder#call(func : LLVM::Function, ...)
、#invoke(fn : LLVM::Function, ...)
分别等效于call(func.function_type, func, ...)
和invoke(fn.function_type, fn, ...)
。请注意,#function_type
已被弃用,在 LLVM 15+ 上不起作用。LLVM::Builder#load(ptr, ...)
这等效于load(ptr.type.element_type, ptr, ...)
。请注意,#element_type
在 LLVM 15 及更高版本上会引发异常。LLVM::Builder#gep(value, ...)
,LLVM::Builder#inbounds_gep(value, ...)
它们分别等效于gep(value.type, value, ...)
和inbounds_gep(value.type, value, ...)
。请注意,#type
在 LLVM 15 及更高版本上仅仅是透明指针类型。
此外,即使 LLVM 15 提供了可选标志来启用类型化指针支持,Crystal 并没有使用此标志,这使得升级到 LLVM 16 及更高版本变得更加容易,因为 LLVM 最终会删除此标志。