CPython是Python的标准实现,它解析源代码并对其进行一些预处理和简化,即"降级",将其转换为一种机器友好、易于解释的格式,称为"
bytecode"。这是当您"反汇编"Python函数时显示的内容。该代码不能由硬件执行 - 它只能由CPython解释器"执行"。CPython的字节码格式相当简单,部分原因是因为解释器通常很擅长处理这种格式 - 如果字节码过于复杂,会使解释器变慢 - 另一部分原因是因为Python社区倾向于简单,有时以高性能为代价。
Julia的实现不是解释性的,而是
即时(JIT)编译。这意味着当您调用函数时,它会被转换为机器代码,直接由本地硬件执行。这个过程比Python的解析和降低到字节码要复杂得多,但作为交换,Julia获得了其标志性的速度。(对于速度来说,增加的复杂性是一个相当典型的代价,例如PyPy JIT对于Python也比CPython复杂得多,但通常也更快。)Julia代码的四个“反汇编”级别使您可以访问特定参数类型的Julia方法实现的表示,以及从源代码到机器代码转换的不同阶段。我将使用以下函数作为示例,该函数计算其参数后面的下一个Fibonacci数:
function nextfib(n)
a, b = one(n), one(n)
while b < n
a, b = b, a + b
end
return b
end
julia> nextfib(5)
5
julia> nextfib(6)
8
julia> nextfib(123)
144
降低的代码。 @code_lowered
宏以最接近 Python 字节码的格式显示代码,但不是用于解释器执行,而是用于编译器进一步转换。这种格式在很大程度上是内部的,不适合人类消费。代码被转换为 "single static assignment" 形式,其中 "每个变量只分配一次,并且每个变量在使用之前都已定义"。循环和条件语句使用单个 unless
/goto
结构转换为跳转和标签(这不会暴露在用户级别的 Julia 中)。下面是我们的示例代码的降低形式(在我手头恰好可用的 Julia 0.6.0-pre.beta.134 中):
julia> @code_lowered nextfib(123)
CodeInfo(:(begin
nothing
SSAValue(0) = (Main.one)(n)
SSAValue(1) = (Main.one)(n)
a = SSAValue(0)
b = SSAValue(1)
7:
unless b < n goto 16
SSAValue(2) = b
SSAValue(3) = a + b
a = SSAValue(2)
b = SSAValue(3)
14:
goto 7
16:
return b
end))
您可以看到
SSAValue
节点和
unless
/
goto
构造以及标签编号。这并不难读,但也不是为了人类阅读而设计的。降低后的代码不依赖于参数的类型,除非它们决定调用哪个方法体 - 只要调用相同的方法,相同的降低代码就适用。
类型化代码。@code_typed
宏在
类型推断和
内联之后,为一组特定的参数类型提供了一个方法实现。这个代码版本类似于降低形式,但表达式带有类型信息,并且一些通用函数调用被它们的实现所替换。例如,这是我们示例函数的类型代码:
julia> @code_typed nextfib(123)
CodeInfo(:(begin
a = 1
b = 1
4:
unless (Base.slt_int)(b, n)::Bool goto 13
SSAValue(2) = b
SSAValue(3) = (Base.add_int)(a, b)::Int64
a = SSAValue(2)
b = SSAValue(3)
11:
goto 4
13:
return b
end))=>Int64
调用
one(n)
的操作已被替换为文字值
Int64
(在我的系统中,默认整数类型是
Int64
)。表达式
b < n
已被替换为基于
slt_int
内置函数(“有符号整数小于”)的实现,并且其结果已被注释为返回类型
Bool
。表达式
a + b
也已被替换为基于
add_int
内置函数的实现,并且其结果类型已被注释为
Int64
。整个函数体的返回类型已被注释为
Int64
。
与降低的代码不同,只依赖于参数类型来确定调用哪个方法体,类型化代码的细节取决于参数类型:
julia> @code_typed nextfib(Int128(123))
CodeInfo(:(begin
SSAValue(0) = (Base.sext_int)(Int128, 1)::Int128
SSAValue(1) = (Base.sext_int)(Int128, 1)::Int128
a = SSAValue(0)
b = SSAValue(1)
6:
unless (Base.slt_int)(b, n)::Bool goto 15
SSAValue(2) = b
SSAValue(3) = (Base.add_int)(a, b)::Int128
a = SSAValue(2)
b = SSAValue(3)
13:
goto 6
15:
return b
end))=>Int128
这是一个针对
Int128
参数的
nextfib
函数的打字版本。字面量
1
必须进行符号扩展,变成
Int128
,同时操作的结果类型为
Int128
而不是
Int64
。如果类型的实现非常不同,那么类型代码可能会有很大的不同。例如,
BigInts
的
nextfib
比简单的“位类型”(如
Int64
和
Int128
)复杂得多。
julia> @code_typed nextfib(big(123))
CodeInfo(:(begin
$(Expr(:inbounds, false))
z@_5 = $(Expr(:invoke, MethodInstance for BigInt(), :(Base.GMP.BigInt)))
$(Expr(:foreigncall, (:__gmpz_set_si, :libgmp), Void, svec(Ptr{BigInt}, Int64), :(&z@_5), :(z@_5), 1, 0))
$(Expr(:inbounds, :pop))
$(Expr(:inbounds, false))
z@_6 = $(Expr(:invoke, MethodInstance for BigInt(), :(Base.GMP.BigInt)))
$(Expr(:foreigncall, (:__gmpz_set_si, :libgmp), Void, svec(Ptr{BigInt}, Int64), :(&z@_6), :(z@_6), 1, 0))
$(Expr(:inbounds, :pop))
a = z@_5
b = z@_6
26:
$(Expr(:inbounds, false))
SSAValue(10) = $(Expr(:foreigncall, (:__gmpz_cmp, :libgmp), Int32, svec(Ptr{BigInt}, Ptr{BigInt}), :(&b), :(b), :(&n), :(n)))
$(Expr(:inbounds, :pop))
unless (Base.slt_int)((Base.sext_int)(Int64, SSAValue(10))::Int64, 0)::Bool goto 46
SSAValue(2) = b
$(Expr(:inbounds, false))
z@_7 = $(Expr(:invoke, MethodInstance for BigInt(), :(Base.GMP.BigInt)))
$(Expr(:foreigncall, ("__gmpz_add", :libgmp), Void, svec(Ptr{BigInt}, Ptr{BigInt}, Ptr{BigInt}), :(&z@_7), :(z@_7), :(&a), :(a), :(&b), :(b)))
$(Expr(:inbounds, :pop))
a = SSAValue(2)
b = z@_7
44:
goto 26
46:
return b
end))=>BigInt
这反映出对BigInts
的操作非常复杂,涉及内存分配和对外部GMP库(libgmp
)的调用。
LLVM IR. Julia使用LLVM编译器框架生成机器代码。 LLVM定义了一种类似汇编的语言,它用作在框架中不同编译器优化通路和其他工具之间共享的中间表示法(IR)。 LLVM IR有三个同构形式:
- 二进制表示法,紧凑且可读性强。
- 文本表示法,冗长且较易读懂。
- 在LLVM库中生成并消耗的内存表示法。
Julia使用LLVM的C++ API在内存中构建LLVM IR (第3种形式),然后对该形式进行一些LLVM优化通路的调用。当您使用@code_llvm
时,您可以看到生成和一些高级优化后的LLVM IR。以下是我们正在进行的示例的LLVM代码:
julia> @code_llvm nextfib(123)
define i64 @julia_nextfib_60009(i64) #0 !dbg !5 {
top:
br label %L4
L4:
%storemerge1 = phi i64 [ 1, %top ], [ %storemerge, %L4 ]
%storemerge = phi i64 [ 1, %top ], [ %2, %L4 ]
%1 = icmp slt i64 %storemerge, %0
%2 = add i64 %storemerge, %storemerge1
br i1 %1, label %L4, label %L13
L13:
ret i64 %storemerge
}
这是
nextfib(123)
方法实现的内存中LLVM IR的文本形式。LLVM不容易阅读 - 大多数情况下它不是为人们编写或阅读而设计的 - 但它是彻底
指定和记录的。一旦你掌握了它,就不难理解。这段代码跳转到标签
L4
并使用
i64
(LLVM对
Int64
的命名)值
1
初始化“寄存器”
%storemerge1
和
%storemerge
(当从不同位置跳转时,它们的值是以不同的方式派生的 - 这就是
phi
指令的作用)。然后进行一个
icmp slt
比较
%storemerge
与寄存器
%0
- 在整个方法执行期间保持不变的参数的值 - 并将比较结果保存到寄存器
%1
中。它在
%storemerge
和
%storemerge1
上执行
add i64
,并将结果保存到寄存器
%2
中。如果
%1
为true,则分支返回到
L4
,否则分支到
L13
。当代码回到
L4
时,寄存器
%storemerge1
获取
%storemerge
的先前值,而
%storemerge
获取
%2
的先前值。
本地代码。由于Julia执行本地代码,因此方法实现的最后形式是机器实际执行的内容。这只是内存中的二进制代码,很难阅读,因此很久以前人们发明了各种形式的“汇编语言”,用名称表示指令和寄存器,并具有一定数量的简单语法来帮助表达指令的作用。总的来说,汇编语言保持接近一对一与机器码的对应关系,特别是可以将机器码“反汇编”为汇编代码。以下是我们的示例:
julia> @code_native nextfib(123)
.section __TEXT,__text,regular,pure_instructions
Filename: REPL[1]
pushq %rbp
movq %rsp, %rbp
movl $1, %ecx
movl $1, %edx
nop
L16:
movq %rdx, %rax
Source line: 4
movq %rcx, %rdx
addq %rax, %rdx
movq %rax, %rcx
Source line: 3
cmpq %rdi, %rax
jl L16
Source line: 6
popq %rbp
retq
nopw %cs:(%rax,%rax)
这是在Intel Core i7上的,它属于x86_64 CPU家族。它只使用标准整数指令,所以架构不重要,但对于某些代码,由于JIT代码在不同系统上可能不同,因此可以根据
您的机器的特定架构获得不同的结果。开头的
pushq
和
movq
指令是标准函数前导,将寄存器保存到堆栈中;类似地,
popq
恢复寄存器,
retq
从函数返回;
nopw
是一个什么也不做的2字节指令,只包含在函数长度上。所以代码的核心就是这样:
movl $1, %ecx
movl $1, %edx
nop
L16:
movq %rdx, %rax
Source line: 4
movq %rcx, %rdx
addq %rax, %rdx
movq %rax, %rcx
Source line: 3
cmpq %rdi, %rax
jl L16
顶部的movl
指令将寄存器初始化为1值。 movq
指令在寄存器之间移动值,addq
指令添加寄存器。 cmpq
指令比较两个寄存器,jl
要么跳回L16
,要么继续从函数返回。 这一小部分整数机器指令在紧密循环中执行,正是在您的Julia函数调用运行时执行的,呈现出稍微更加人性化可读的形式。很容易看出它为什么运行得快。
如果您对JIT编译总体感兴趣,与解释实现相比,Eli Bendersky有一系列出色的博客文章,在这些文章中,他从一个简单的语言解释器实现到同一语言的(简单)优化JIT:
- http://eli.thegreenplace.net/2017/adventures-in-jit-compilation-part-1-an-interpreter/
- http://eli.thegreenplace.net/2017/adventures-in-jit-compilation-part-2-an-x64-jit.html