Ahead of Time Compilation

本文档描述了Julia中提前编译(AOT)系统的设计和结构。该系统在生成系统映像和包映像时使用。这里描述的大部分实现位于aotcompile.cppstaticdata.cprocessor.cpp中。

Introduction

尽管 Julia 通常是即时编译代码 (JIT),但可以提前编译代码并将生成的代码保存到文件中。这可能出于多种原因而有用:

  1. 为了减少启动 Julia 进程所需的时间。
  2. 为了减少在 JIT 编译器中花费的时间,而不是执行代码(首次执行时间,TTFX)。
  3. 为了减少 JIT 编译器使用的内存量。

High-Level Overview

以下描述是用户编译新 AOT 模块时内部发生的端到端管道的当前实现细节快照,例如当他们输入 using Foo 时。这些细节可能会随着我们实施更好的处理方式而变化,因此当前的实现可能与下面描述的数据流和功能不完全匹配。

Compiling Code Images

首先,需要识别需要编译为本机代码的方法。这只能通过实际执行要编译的代码来完成,因为需要编译的方法集取决于传递给方法的参数类型,而某些类型组合的方法调用可能在运行时之前是未知的。在此过程中,编译器看到的确切方法会被跟踪以便后续编译,从而生成编译跟踪。

Note

目前,在编译图像时,Julia 在与执行 AOT 编译的进程不同的进程中运行跟踪生成。这在尝试在预编译期间使用调试器时可能会产生影响。使用调试器调试预编译的最佳方法是使用 rr 调试器,记录整个进程树,使用 rr ps 来识别相关的失败进程,然后使用 rr replay -p PID 仅重放失败的进程。

一旦要编译的方法被识别出来,它们就会被传递给 jl_create_system_image 函数。该函数设置了一些数据结构,这些数据结构将在将本机代码序列化到文件时使用,然后调用 jl_create_native 并传入方法数组。jl_create_native 对方法进行代码生成,生成一个或多个 LLVM 模块。然后,jl_create_system_image 记录一些关于代码生成从模块中产生的有用信息。

模块随后被传递给 jl_dump_native,以及由 jl_create_system_image 记录的信息。jl_dump_native 包含将模块序列化为位码、目标文件或汇编文件所需的代码,这取决于传递给 Julia 的命令行选项。序列化的代码和信息随后作为归档写入文件。

最后一步是对由 jl_dump_native 生成的归档中的目标文件运行系统链接器。一旦这一步完成,就会生成一个包含编译代码的共享库。

Loading Code Images

当加载代码映像时,链接器生成的共享库被加载到内存中。系统映像数据随后从共享库中加载。该数据包含有关编译到共享库中的类型、方法和代码实例的信息。此数据用于将运行时的状态恢复到代码映像编译时的状态。

如果代码图像是使用多版本编译的,加载器将根据当前机器上可用的 CPU 特性选择每个函数的适当版本。

对于系统镜像,由于没有加载其他代码,运行时的状态现在与代码镜像编译时相同。对于包镜像,环境可能与代码编译时有所不同,因此必须检查每个方法是否仍然是有效代码,以便与全局方法表进行比较。

Compiling Methods

Tracing Compiled Methods

Julia 有一个命令行标志,用于记录所有由 JIT 编译器编译的方法,--trace-compile=filename。当一个函数被编译并且这个标志有一个文件名时,Julia 会将一个预编译语句打印到该文件中,包含调用时的方法和参数类型。因此,这会生成一个预编译脚本,可以在 AOT 编译过程中稍后使用。PrecompileTools 包含的工具可以使包开发者更容易利用这一功能。

jl_create_system_image

jl_create_system_image 保存了所有必要的 Julia 特定元数据,以便后续恢复运行时的状态。这包括代码实例、方法实例、方法表和类型信息等数据。此函数还设置了将本机代码序列化到文件所需的数据结构。最后,它调用 jl_create_native 来创建一个或多个包含传递给它的方法的本机代码的 LLVM 模块。jl_create_native 负责对传递给它的方法进行代码生成。

jl_dump_native

jl_dump_native 负责将包含本机代码的 LLVM 模块序列化到文件中。除了模块之外,由 jl_create_system_image 生成的系统映像数据被编译为全局变量。此方法的输出是包含代码和系统映像数据的位码、目标文件和/或汇编档案。

jl_dump_native 通常是发出本机代码时最大的时间消耗之一,其中大部分时间花费在优化 LLVM IR 和发出机器代码上。因此,该函数能够对优化和机器代码发出步骤进行多线程处理。此多线程处理是基于模块的大小进行参数化的,但可以通过设置 JULIA_IMAGE_THREADS 环境变量显式覆盖。默认的最大线程数是可用线程数的一半,但将其设置为更低的值可以在编译期间减少峰值内存使用。

jl_dump_native 还可以生成针对多种架构优化的本地代码,当与 Julia 加载器集成时。这是通过设置 JULIA_CPU_TARGET 环境变量来触发的,并通过优化管道中的多版本处理步骤进行调解。为了使其与多线程一起工作,在模块被拆分成在各自线程中发出的子模块之前,添加了一个注释步骤,这个注释步骤使用整个模块中可用的信息来决定哪些函数会为不同的架构克隆。一旦注释完成,单独的线程可以并行发出不同架构的代码,知道不同的子模块保证会生成被克隆函数调用的必要函数。

关于模块序列化的其他一些元数据也存储在归档中,例如用于序列化模块的线程数和编译的函数数量。

Static Linking

AOT 编译过程的最后一步是对由 jl_dump_native 生成的归档中的目标文件运行链接器。这会生成一个包含编译代码的共享库。然后,Julia 可以加载这个共享库以恢复运行时的状态。在编译系统映像时,使用 C 编译器的本地链接器来生成最终的共享库。对于包映像,使用 LLVM 链接器 LLD 来提供更一致的链接接口。

Loading Code Images

Loading the Shared Library

加载代码映像的第一步是加载链接器生成的共享库。这是通过在共享库的路径上调用 jl_dlopen 来完成的。此函数负责加载共享库并解析库中的所有符号。

Loading Native Code

加载器首先需要确定编译的本机代码是否适用于加载器正在运行的架构。这是必要的,以避免执行旧 CPU 无法识别的指令。这是通过检查当前机器上可用的 CPU 特性与代码编译时所针对的 CPU 特性进行比较来完成的。当启用多版本时,加载器将根据当前机器上可用的 CPU 特性选择每个函数的适当版本。如果没有任何多版本的特性集,加载器将抛出错误。

多版本处理的部分会创建模块中所有函数的多个全局数组。当这个过程是多线程时,会创建一个数组的数组,加载器将其重新组织成一个包含为该架构编译的所有函数的大数组。模块中的全局变量也会发生类似的过程。

Setting Up Julia State

加载器随后使用从加载本地代码生成的全局变量和函数,在当前进程中设置Julia运行时核心数据结构。此设置涉及向Julia运行时添加类型和方法,并使缓存的本地代码可供其他Julia函数和解释器使用。对于包图像,每个方法必须经过验证,即全局方法表的状态必须与包图像编译时的状态匹配。特别是,如果在加载时存在与包图像编译时不同的一组方法,则该方法必须在首次使用时失效并重新编译。这是必要的,以确保执行语义保持一致,无论包是预编译的还是代码是直接执行的。系统图像不需要执行此验证,因为在加载时全局方法表是空的。因此,系统图像的加载时间比包图像更快。