Eval of Julia code
学习Julia语言如何运行代码最困难的部分之一是了解所有组件如何协同工作以执行一段代码。
每段代码通常会经过许多步骤,这些步骤可能有不熟悉的名称,例如(无特定顺序):flisp、AST、C++、LLVM、eval
、typeinf
、macroexpand
、sysimg(或系统映像)、引导、编译、解析、执行、JIT、解释、装箱、拆箱、内在函数和原始函数,然后才会变成期望的结果(希望如此)。
Julia Execution
整个过程的10,000英尺视图如下:
- 用户启动
julia
。 - C 函数
main()
从cli/loader_exe.c
被调用。该函数处理命令行参数,填充jl_options
结构并设置变量ARGS
。然后它通过调用julia_init
ininit.c
来初始化 Julia,这可能会加载一个先前编译的 sysimg。最后,它通过调用Base._start()
将控制权交给 Julia。 - 当
_start()
接管控制时,后续的命令序列取决于给定的命令行参数。例如,如果提供了文件名,它将继续执行该文件。否则,它将启动一个交互式 REPL。 - 跳过关于 REPL 如何与用户交互的细节,我们只需说程序最终得到了一段它想要运行的代码块。
- 如果要运行的代码块在文件中,
jl_load(char *filename)
被调用以加载文件,并且 parse 也被调用。然后每个代码片段都被传递给eval
以执行。 - 每个代码片段(或 AST)都被交给
eval()
以生成结果。 eval()
将每个代码片段提取并尝试在jl_toplevel_eval_flex()
中运行。jl_toplevel_eval_flex()
决定代码是否为“顶层”操作(例如using
或module
),这在函数内部是无效的。如果是,它会将代码传递给顶层解释器。jl_toplevel_eval_flex()
然后 expands 这段代码用于消除任何宏,并将 AST "降低" 以简化执行。jl_toplevel_eval_flex()
然后使用一些简单的启发式方法来决定是 JIT 编译 AST 还是直接解释它。- 大部分代码解释的工作由
eval
ininterpreter.c
处理。 - 如果代码被编译,大部分工作由
codegen.cpp
处理。每当一个 Julia 函数第一次以给定的参数类型被调用时,type inference 将在该函数上运行。这些信息被 codegen 步骤用来生成更快的代码。 - 最终,用户退出 REPL,或者程序结束,
_start()
方法返回。 - 在退出之前,
main()
调用jl_atexit_hook(exit_code)
。这会调用Base._atexit()
(它会调用在 Julia 中注册的任何函数atexit()
)。然后它调用jl_gc_run_all_finalizers()
。最后,它优雅地清理所有libuv
句柄,并等待它们刷新和关闭。
Parsing
Julia 解析器是一个用 femtolisp 编写的小型 lisp 程序,其源代码分布在 Julia 内部,位于 src/flisp。
该接口的功能主要在 jlfrontend.scm
中定义。代码 ast.c
处理了 Julia 端的这个交接。
在这个阶段,其他相关文件是 julia-parser.scm
,它处理将 Julia 代码进行标记化并转换为 AST,以及 julia-syntax.scm
,它处理将复杂的 AST 表示转换为更简单的“降低”AST 表示,这些表示更适合分析和执行。
如果您想在不完全重建 Julia 的情况下测试解析器,可以单独运行前端,如下所示:
$ cd src
$ flisp/flisp
> (load "jlfrontend.scm")
> (jl-parse-file "<filename>")
Macro Expansion
当 eval()
遇到宏时,它会在尝试评估表达式之前扩展该 AST 节点。宏扩展涉及从 4d61726b646f776e2e436f64652822222c20226576616c28292229_40726566
(在 Julia 中)到解析器函数 jl_macroexpand()
(用 flisp
编写),再到 Julia 宏本身(用 - 还有什么 - Julia 编写)通过 fl_invoke_julia_macro()
,然后再返回。
通常,宏扩展在调用 Meta.lower()
/jl_expand()
时作为第一步被调用,尽管它也可以通过调用 macroexpand()
/jl_macroexpand()
直接调用。
Type Inference
类型推断在 Julia 中的实现是通过 typeinf()
in compiler/typeinfer.jl
。类型推断是检查 Julia 函数并确定其每个变量类型的界限,以及函数返回值类型的界限的过程。这使得许多未来的优化成为可能,例如已知不可变值的解包,以及在编译时提升各种运行时操作,如计算字段偏移量和函数指针。类型推断还可能包括其他步骤,例如常量传播和内联。
JIT Code Generation
Codegen 是将 Julia AST 转换为本机机器代码的过程。
JIT 环境通过对 jl_init_codegen
in codegen.cpp
的早期调用进行初始化。
根据需要,Julia 方法通过函数 emit_function(jl_method_instance_t*)
转换为本地函数。(注意,当使用 MCJIT(在 LLVM v3.4+ 中)时,每个函数必须 JIT 到一个新模块中。)该函数递归调用 emit_expr()
,直到整个函数被发出。
此文件剩余的大部分内容专门用于对特定代码模式的各种手动优化。例如,emit_known_call()
知道如何内联许多原始函数(在 builtins.c
中定义)以适应各种参数类型的组合。
代码生成的其他部分由各种辅助文件处理:
处理 JIT 函数的回溯
处理 ccall 和 llvmcall FFI,以及各种
abi_*.cpp
文件处理各种低级内置函数的发射
System Image
系统映像是一个预编译的 Julia 文件集的归档。与 Julia 一起分发的 sys.ji
文件就是这样一个系统映像,它是通过执行文件 sysimg.jl
并将生成的环境(包括类型、函数、模块和所有其他定义的值)序列化到文件中而生成的。因此,它包含了 Main
、Core
和 Base
模块的冻结版本(以及引导结束时环境中的其他内容)。这个序列化/反序列化器是通过 jl_save_system_image
/jl_restore_system_image
in staticdata.c
实现的。
如果没有 sysimg 文件(jl_options.image_file == NULL
),这也意味着在命令行中给出了 --build
,因此最终结果应该是一个新的 sysimg 文件。在 Julia 初始化期间,创建了最小的 Core
和 Main
模块。然后从当前目录评估一个名为 boot.jl
的文件。Julia 然后评估作为命令行参数给出的任何文件,直到达到末尾。最后,它将结果环境保存到一个 "sysimg" 文件中,以便作为未来 Julia 运行的起点。