跳到内容
GitHub 仓库 论坛 RSS 新闻提要

LLVM 不透明指针支持已落地

HertzDevil

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 最终会删除此标志。