@code_native、@code_typed和@code_llvm在Julia中有什么区别?

39

在学习Julia的过程中,我希望能够拥有类似Python的dis模块的功能。 在网上查找后,我发现Julia社区已经解决了这个问题,并给出了这些解决方法(https://github.com/JuliaLang/julia/issues/218

finfer -> code_typed
methods(function, types) -> code_lowered
disassemble(function, types, true) -> code_native
disassemble(function, types, false) -> code_llvm

我个人尝试使用Julia REPL,但我似乎很难理解。

在Python中,我可以像这样反汇编函数。

>>> import dis
>>> dis.dis(lambda x: 2*x)
  1           0 LOAD_CONST               1 (2)
              3 LOAD_FAST                0 (x)
              6 BINARY_MULTIPLY     
              7 RETURN_VALUE        
>>>

有没有曾经使用过这些工具的人可以帮助我更好地理解它们?谢谢。

1个回答

83
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) # 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构造以及标签编号。这并不难读,但也不是为了人类阅读而设计的。降低后的代码不依赖于参数的类型,除非它们决定调用哪个方法体 - 只要调用相同的方法,相同的降低代码就适用。 类型化代码。@code_typed宏在类型推断内联之后,为一组特定的参数类型提供了一个方法实现。这个代码版本类似于降低形式,但表达式带有类型信息,并且一些通用函数调用被它们的实现所替换。例如,这是我们示例函数的类型代码:
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(在我的系统中,默认整数类型是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) # 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

这是一个针对 Int128 参数的 nextfib 函数的打字版本。字面量 1 必须进行符号扩展,变成 Int128,同时操作的结果类型为 Int128 而不是 Int64。如果类型的实现非常不同,那么类型代码可能会有很大的不同。例如,BigIntsnextfib 比简单的“位类型”(如 Int64Int128)复杂得多。
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. Julia使用LLVM编译器框架生成机器代码。 LLVM定义了一种类似汇编的语言,它用作在框架中不同编译器优化通路和其他工具之间共享的中间表示法(IR)。 LLVM IR有三个同构形式:

  1. 二进制表示法,紧凑且可读性强。
  2. 文本表示法,冗长且较易读懂。
  3. 在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
}

这是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代码在不同系统上可能不同,因此可以根据您的机器的特定架构获得不同的结果。开头的pushqmovq指令是标准函数前导,将寄存器保存到堆栈中;类似地,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:

  1. http://eli.thegreenplace.net/2017/adventures-in-jit-compilation-part-1-an-interpreter/
  2. http://eli.thegreenplace.net/2017/adventures-in-jit-compilation-part-2-an-x64-jit.html

我在Julia窗口中尝试了@code_native 3^2,它输出了一些代码。但是当我将其复制到VS Code窗口并运行时,它会显示"@code_native"未定义。我已安装Julia 1.5和环境。 - fsteveb
你在这里可能得不到关于那个问题的帮助。你可以尝试访问https://discourse.julialang.org。 - StefanKarpinski

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接