Python 的标准 CPython 实现会解析源代码,并对其进行一些预处理和简化——又名“降低”——将其转换为机器友好、易于解释的格式,称为“bytecode https://en.wikipedia.org/wiki/Bytecode“。这是当您“反汇编”Python 函数时显示的内容。该代码不能由硬件执行 - 它可以由 CPython 解释器“执行”。CPython 的字节码格式相当简单,部分原因是解释器倾向于这样做很好——如果字节码太复杂,它会减慢解释器的速度——部分原因是Python社区倾向于高度重视简单性,有时会以高性能为代价。
Julia 的实现不是解释性的,而是即时 (JIT) 编译 https://en.wikipedia.org/wiki/Just-in-time_compilation。这意味着当您调用函数时,它会转换为由本机硬件直接执行的机器代码。这个过程比 Python 所做的解析和转换为字节码要复杂得多,但作为这种复杂性的交换,Julia 获得了其标志性的速度。 (Python 的 PyPy JIT 也比 CPython 复杂得多,但速度通常也快得多 - 复杂性的增加是相当典型的速度成本。)Julia 代码的四个级别的“反汇编”使您可以访问 Julia 方法的表示从源代码到机器代码转换的不同阶段的特定参数类型的实现。我将使用以下函数来计算其参数后的下一个斐波那契数作为示例:
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
降低代码。 The @code_lowered
宏以最接近 Python 字节码的格式显示代码,但它不是供解释器执行,而是供编译器进一步转换。这种格式主要是内部格式,不适合人类使用。代码转换为“单一静态赋值 https://en.wikipedia.org/wiki/Static_single_assignment_form” 形式,其中“每个变量只被分配一次,并且每个变量在使用之前都被定义”。循环和条件语句使用单个变量转换为 goto 和标签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) # line 3:
7:
unless b < n goto 16 # line 4:
SSAValue(2) = b
SSAValue(3) = a + b
a = SSAValue(2)
b = SSAValue(3)
14:
goto 7
16: # line 6:
return b
end))
您可以看到SSAValue
节点和unless
/goto
结构和标签编号。这并不难读,但同样,它也并不意味着易于人类消费。降低的代码不依赖于参数的类型,除非它们确定要调用哪个方法主体 - 只要调用相同的方法,就应用相同的降低的代码。
键入代码。 The @code_typed
宏提供了一组特定参数类型的方法实现类型推断 https://stackoverflow.com/questions/28078089/is-julia-dynamically-typed/28096079#28096079 and inlining https://en.wikipedia.org/wiki/Inline_expansion。代码的这种形式与降低的形式类似,但表达式用类型信息注释,并且一些通用函数调用替换为它们的实现。例如,以下是我们示例函数的类型代码:
julia> @code_typed nextfib(123)
CodeInfo(:(begin
a = 1
b = 1 # line 3:
4:
unless (Base.slt_int)(b, n)::Bool goto 13 # line 4:
SSAValue(2) = b
SSAValue(3) = (Base.add_int)(a, b)::Int64
a = SSAValue(2)
b = SSAValue(3)
11:
goto 4
13: # line 6:
return b
end))=>Int64
致电one(n)
已被替换为字面意思Int64
value 1
(在我的系统上默认整数类型是Int64
)。表达方式b < n
已被其实施所取代slt_int
固有的 https://en.wikipedia.org/wiki/Intrinsic_function(“有符号整数小于”)并且其结果已用返回类型注释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) # line 3:
6:
unless (Base.slt_int)(b, n)::Bool goto 15 # line 4:
SSAValue(2) = b
SSAValue(3) = (Base.add_int)(a, b)::Int128
a = SSAValue(2)
b = SSAValue(3)
13:
goto 6
15: # line 6:
return b
end))=>Int128
这是键入的版本nextfib
函数为Int128
争论。字面意思1
必须符号扩展为Int128
操作的结果类型为Int128
代替Int64
。如果类型的实现有很大不同,则类型化代码可能会有很大不同。例如nextfib
for BigInts
比简单的“位类型”涉及更多,例如Int64
and Int128
:
julia> @code_typed nextfib(big(123))
CodeInfo(:(begin
$(Expr(:inbounds, false))
# meta: location number.jl one 164
# meta: location number.jl one 163
# meta: location gmp.jl convert 111
z@_5 = $(Expr(:invoke, MethodInstance for BigInt(), :(Base.GMP.BigInt))) # line 112:
$(Expr(:foreigncall, (:__gmpz_set_si, :libgmp), Void, svec(Ptr{BigInt}, Int64), :(&z@_5), :(z@_5), 1, 0))
# meta: pop location
# meta: pop location
# meta: pop location
$(Expr(:inbounds, :pop))
$(Expr(:inbounds, false))
# meta: location number.jl one 164
# meta: location number.jl one 163
# meta: location gmp.jl convert 111
z@_6 = $(Expr(:invoke, MethodInstance for BigInt(), :(Base.GMP.BigInt))) # line 112:
$(Expr(:foreigncall, (:__gmpz_set_si, :libgmp), Void, svec(Ptr{BigInt}, Int64), :(&z@_6), :(z@_6), 1, 0))
# meta: pop location
# meta: pop location
# meta: pop location
$(Expr(:inbounds, :pop))
a = z@_5
b = z@_6 # line 3:
26:
$(Expr(:inbounds, false))
# meta: location gmp.jl < 516
SSAValue(10) = $(Expr(:foreigncall, (:__gmpz_cmp, :libgmp), Int32, svec(Ptr{BigInt}, Ptr{BigInt}), :(&b), :(b), :(&n), :(n)))
# meta: pop location
$(Expr(:inbounds, :pop))
unless (Base.slt_int)((Base.sext_int)(Int64, SSAValue(10))::Int64, 0)::Bool goto 46 # line 4:
SSAValue(2) = b
$(Expr(:inbounds, false))
# meta: location gmp.jl + 258
z@_7 = $(Expr(:invoke, MethodInstance for BigInt(), :(Base.GMP.BigInt))) # line 259:
$(Expr(:foreigncall, ("__gmpz_add", :libgmp), Void, svec(Ptr{BigInt}, Ptr{BigInt}, Ptr{BigInt}), :(&z@_7), :(z@_7), :(&a), :(a), :(&b), :(b)))
# meta: pop location
$(Expr(:inbounds, :pop))
a = SSAValue(2)
b = z@_7
44:
goto 26
46: # line 6:
return b
end))=>BigInt
这反映了这样一个事实:BigInts
非常复杂,涉及内存分配和对外部 GMP 库的调用(libgmp
).
LLVM IR.朱莉娅使用LLVM编译器框架 http://llvm.org/生成机器代码。 LLVM 定义了一种类似汇编的语言,它用作共享中间表示 https://en.wikipedia.org/wiki/Intermediate_representation(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: ; preds = %L4, %top
%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: ; preds = %L4
ret i64 %storemerge
}
这是内存中 LLVM IR 的文本形式nextfib(123)
方法实施。 LLVM 并不容易阅读——大多数时候它并不是为了让人们编写或阅读而设计的——但它是彻底的指定并记录 http://llvm.org/docs/LangRef.html。一旦掌握了它的窍门,就不难理解了。这段代码跳转到标签处L4
并初始化“寄存器”%storemerge1
and %storemerge
与i64
(LLVM 的名称为Int64
) value 1
(当从不同的位置跳转到时,它们的值会得到不同的结果 - 这就是phi
指令确实)。然后它会执行一个icmp slt
比较%storemerge
带寄存器%0
– 在整个方法执行过程中保持参数不变 – 并将比较结果保存到寄存器中%1
。它做了一个add i64
on %storemerge
and %storemerge1
并将结果保存到寄存器中%2
. If %1
是真的,它分支回到L4
否则它分支到L13
。当代码循环回到L4
登记册%storemerge1
获取之前的值%storemerge
and %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 系列。它仅使用标准整数指令,因此架构是什么并不重要,但是根据特定的架构,某些代码可能会得到不同的结果your机,因为 JIT 代码在不同的系统上可能不同。这pushq
and 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
The 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-1-an-interpreter/
- http://eli.thegreenplace.net/2017/adventures-in-jit-compilation-part-2-an-x64-jit.html http://eli.thegreenplace.net/2017/adventures-in-jit-compilation-part-2-an-x64-jit.html