Julia 中的 @code_native、@code_typed 和 @code_llvm 有啥区别?

Posted

技术标签:

【中文标题】Julia 中的 @code_native、@code_typed 和 @code_llvm 有啥区别?【英文标题】:What is the difference between @code_native, @code_typed and @code_llvm in Julia?Julia 中的 @code_native、@code_typed 和 @code_llvm 有什么区别? 【发布时间】:2017-09-13 05:12:02 【问题描述】:

在使用 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】:

Python 的标准 CPython 实现会解析源代码并对其进行一些预处理和简化(也称为“降低”),将其转换为一种机器友好、易于解释的格式,称为“bytecode”。这是“反汇编” Python 函数时显示的内容。此代码不能由硬件执行 - 它可以由 CPython 解释器“执行”。 CPython 的字节码格式相当简单,部分原因是解释器倾向于使用这种格式——如果字节码太复杂,它会减慢解释器的速度——部分原因是 Python 社区倾向于高度重视简单性,有时会付出代价高性能。

Julia 的实现没有被解释,它是just-in-time (JIT) compiled。这意味着当您调用一个函数时,它会被转换为机器代码,由本机硬件直接执行。这个过程比 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

降低的代码。@code_lowered 宏以最接近 Python 字节码的格式显示代码,但不是由解释器执行,而是由编译器。这种格式主要是内部的,不适合人类使用。代码被转换为“single static assignment”形式,其中“每个变量只分配一次,每个变量在使用前都定义好”。使用单个 unless/goto 构造将循环和条件转换为 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 宏在type inference 和inlining 之后为一组特定的参数类型提供了方法实现。代码的这种化身类似于降级形式,但使用类型信息注释的表达式和一些通用函数调用替换为它们的实现。例如,这里是我们示例函数的类型代码:

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) 的调用已替换为文字Int641(在我的系统上,默认整数类型为Int64)。表达式 b &lt; n 已被替换为它的实现,它以 slt_int intrinsic(“有符号整数小于”)表示,并且其结果已用返回类型 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。如果类型的实现有很大不同,则类型化的代码可能会有很大的不同。例如,nextfibBigIntsInt64Int128 等简单的“位类型”涉及更多:

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(PtrBigInt, 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(PtrBigInt, 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(PtrBigInt, PtrBigInt), :(&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(PtrBigInt, PtrBigInt, PtrBigInt), :(&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 compiler framework 生成机器代码。 LLVM 定义了一种类似汇编的语言,它用作不同编译器优化通道和框架中其他工具之间的共享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

这是nextfib(123) 方法实现的内存中 LLVM IR 的文本形式。 LLVM 不易阅读——大多数时候它并不打算由人们编写或阅读——但它完全是specified and documented。一旦掌握了窍门,就不难理解了。此代码跳转到标签L4 并使用i64(LLVM 的Int64 名称)值1 初始化“寄存器”%storemerge1%storemerge(当从不同的跳转到位置——这就是phi 指令的作用)。然后它执行icmp slt 比较%storemerge 和寄存器%0——在整个方法执行过程中保持参数不变——并将比较结果保存到寄存器%1。它对%storemerge%storemerge1 执行add i64 并将结果保存到寄存器%2。如果%1 为真,则分支回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)

这是在 x86_64 CPU 系列中的 Intel Core i7 上。它只使用标准的整数指令,所以除了架构是什么无关紧要,但是你可以根据你的机器的具体架构对某些代码得到不同的结果,因为 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 :

    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

【讨论】:

我在 Julia 窗口中尝试了@code_native 3^2,它吐出了一些代码。当我复制到 VS Code 窗口时,运行时显示“@code_native”未定义。 Julia 1.5 安装和环境。 你不太可能在这里得到帮助。你可能想试试discourse.julialang.org

以上是关于Julia 中的 @code_native、@code_typed 和 @code_llvm 有啥区别?的主要内容,如果未能解决你的问题,请参考以下文章

IPython Jupyter 中的 Julia 并行计算

Julia 与 Linux 中的 VS Code 集成

Julia 中的声音激活录音

Julia中的内核PCA实现

优化中的 Julia 抽象类型?

了解 Julia 中的多线程行为