Julia语言中 <: Any 的性能表现

3

我刚开始学习Julia,想在养成不好的习惯之前先了解一些构造的性能影响。目前,我正在尝试理解Julia的类型系统,特别是<: Any类型注释。据我所知,<: Any应该代表我不关心类型

考虑以下代码:

struct Container{T}
    parametric::T
    nonparametric::Int64
end

struct TypeAny
    payload::Container{<: Any}
end

struct TypeKnown
    payload::Container{Array{Int64,1}}
end

getparametric(x) = x.payload.parametric[1]
getnonparametric(x) = x.payload.nonparametric

xany = TypeAny(Container([1], 2))
xknown = TypeKnown(Container([1], 2))

@time for i in 1:10000000 getparametric(xany) end         # 0.212002s
@time for i in 1:10000000 getparametric(xknown) end       # 0.110531s

@time for i in 1:10000000 getnonparametric(xany) end      # 0.173390s
@time for i in 1:10000000 getnonparametric(xknown) end    # 0.086739s

首先,我对于getparametric(xany)能够在作用于未知类型的字段Container{<: Any}.parametric时正常工作感到惊讶。这是如何实现的?这种构造的性能影响是什么?Julia在幕后做了一些运行时反射吗?还是有更复杂的事情正在发生?
其次,我对于调用getnonparametric(xany)getnonparametric(xknown)之间的运行时差异感到惊讶,这与我使用类型注释<: Any作为“我不关心”的注释的直觉相矛盾。为什么调用getnonparametric(xany)明显较慢,即使我只使用已知类型的字段?如果我不想使用某个类型的任何变量但又不想影响性能,该怎么办?(在我的应用程序中,似乎无法指定具体类型,因为那会导致无限递归类型定义 - 但这可能是由于我的代码设计不当而引起的问题,超出了本问题的范围。)
1个回答

3

<: Any 应该代表我不关心类型。

它类似于 可以是任何类型 (因此编译器对类型没有任何提示)。你也可以这样写:

struct TypeAny
    payload::Container
end

这基本上与以下测试结果相同:

julia> Container{<:Any} <: Container
true

julia> Container <: Container{<:Any}
true

这种构造方式可能会导致运行时确定您容器中持有的对象的具体类型,而不是在编译时确定(正如您所怀疑的那样),因此会影响性能。
但请注意,如果将这样的提取出来的对象传递给一个函数,那么在调用函数内部进行动态分派后,代码将运行得很快(因为它将是类型稳定的)。您可以在这里了解更多信息。
对于位类型,更复杂的事情发生了。如果某个具体的位类型是容器中的字段,则其作为值存储。如果其类型在编译时未知,则将其存储为引用(这将有额外的内存和运行时影响)。
如上所述,运行时之间的差异是由于在编译时该字段的类型未知。如果您更改定义为:
struct TypeAny{T}
    payload::Container{T}
end

你说“我不关心类型,但将其存储在参数中”,这样编译器就会知道这个类型。

然后,payload 的类型将在编译时得到确认,所有东西都会更快。

如果以上内容有不清楚的地方或需要更多解释,请留言,我会扩展回答。

顺便说一句,通常最好使用BenchmarkTools.jl来分析代码的性能(除非您想测量编译时间也)。

编辑

请查看:

julia> loop(x) = for i in 1:10000000 getnonparametric(x) end
loop (generic function with 1 method)

julia> @code_native loop(xknown)
        .text
; ┌ @ REPL[14]:1 within `loop'
        pushq   %rbp
        movq    %rsp, %rbp
        pushq   %rax
        movq    %rdx, -8(%rbp)
        movl    $74776584, %eax         # imm = 0x4750008
        addq    $8, %rsp
        popq    %rbp
        retq
; └

julia> @code_native loop(xany)
        .text
; ┌ @ REPL[14]:1 within `loop'
        pushq   %rbp
        movq    %rsp, %rbp
        pushq   %rax
        movq    %rdx, -8(%rbp)
        movl    $74776584, %eax         # imm = 0x4750008
        addq    $8, %rsp
        popq    %rbp
        retq
; └

您可以看到编译器足够聪明,优化出整个循环(因为它本质上是无操作)。这就是 Julia 的强大之处(但另一方面也使得有时候进行基准测试很困难)。

以下示例向您展示了更准确的视图(请注意我使用了更复杂的表达式,因为即使是非常简单的表达式在循环中也可以被编译器优化掉):

julia> xknowns = fill(xknown, 10^6);

julia> xanys = fill(xany, 10^6);

julia> @btime sum(getnonparametric, $xanys)
  12.373 ms (0 allocations: 0 bytes)
2000000

julia> @btime sum(getnonparametric, $xknowns)
  519.700 μs (0 allocations: 0 bytes)
2000000

请注意,即使在这种情况下,编译器也足够“聪明”,能够正确地推断出表达式的返回类型,在这两种情况下,你都访问了非参数字段。
julia> @code_warntype sum(getnonparametric, xanys)
Variables
  #self#::Core.Compiler.Const(sum, false)
  f::Core.Compiler.Const(getnonparametric, false)
  a::Array{TypeAny,1}

Body::Int64
1nothing
│   %2 = Base.:(#sum#559)(Base.:(:), #self#, f, a)::Int64
└──      return %2

julia> @code_warntype sum(getnonparametric, xknowns)
Variables
  #self#::Core.Compiler.Const(sum, false)
  f::Core.Compiler.Const(getnonparametric, false)
  a::Array{TypeKnown,1}

Body::Int64
1nothing
│   %2 = Base.:(#sum#559)(Base.:(:), #self#, f, a)::Int64
└──      return %2

当你查看在两种情况下生成的本地代码时,差异的核心就可以看到:

julia> @code_native getnonparametric(xany)
        .text
; ┌ @ REPL[6]:1 within `getnonparametric'
        pushq   %rbp
        movq    %rsp, %rbp
; │┌ @ Base.jl:20 within `getproperty'
        subq    $48, %rsp
        movq    (%rcx), %rax
        movq    %rax, -16(%rbp)
        movq    $75966808, -8(%rbp)     # imm = 0x4872958
        movabsq $jl_f_getfield, %rax
        leaq    -16(%rbp), %rdx
        xorl    %ecx, %ecx
        movl    $2, %r8d
        callq   *%rax
; │└
        movq    (%rax), %rax
        addq    $48, %rsp
        popq    %rbp
        retq
        nopl    (%rax,%rax)
; └

julia> @code_native getnonparametric(xknown)
        .text
; ┌ @ REPL[6]:1 within `getnonparametric'
        pushq   %rbp
        movq    %rsp, %rbp
; │┌ @ Base.jl:20 within `getproperty'
        movq    (%rcx), %rax
; │└
        movq    8(%rax), %rax
        popq    %rbp
        retq
        nopl    (%rax)
; └

如果您给类型添加参数,则一切都按预期工作:

julia> struct Container{T}
           parametric::T
           nonparametric::Int64
       end

julia> struct TypeAny2{T}
           payload::Container{T}
       end

julia> xany2 = TypeAny2(Container([1], 2))
TypeAny2{Array{Int64,1}}(Container{Array{Int64,1}}([1], 2))

julia> @code_native getnonparametric(xany2)
        .text
; ┌ @ REPL[9]:1 within `getnonparametric'
        pushq   %rbp
        movq    %rsp, %rbp
; │┌ @ Base.jl:20 within `getproperty'
        movq    (%rcx), %rax
; │└
        movq    8(%rax), %rax
        popq    %rbp
        retq
        nopl    (%rax)
; └

你有:

julia> xany2s = fill(xany2, 10^6);

julia> @btime sum(getnonparametric, $xany2s)
  528.699 μs (0 allocations: 0 bytes)
2000000

总结

  1. 如果想要提高性能,请尽可能使用没有抽象类型字段的容器。
  2. 有时候,如果第1点中的条件不符合,编译器可以有效地处理并生成快速的机器代码,但通常不能保证(因此第1点中的建议仍然适用)。

非常感谢您详细的回答,解决了我提出的问题!如果我理解正确,getnonparametric 方法的关键性能问题在于它被循环调用(并且每次迭代都执行动态分派)。如果我将循环包装在一个函数 loop(x) = for i in 1:10000000 getnonparametric(x) end 中,引入函数屏障并只执行一次分派,代码会快得多(对于 @btime loop(xknown)@btime loop(xany) 都是 8ns)。谢谢! - Karel Horak
不幸的是,这件事更加复杂。我将在我的回答编辑中详细阐述。 - Bogumił Kamiński

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