Engee 文档

性能提示

在以下部分中,我们将简要地介绍一些可以帮助尽可能加快Julia代码执行速度的技术。

性能很重要的代码应该放在函数中。

任何性能很重要的代码都应该放在函数中。 由于Julia编译器的工作方式,函数内部的代码通常比顶级代码快得多。

函数不仅在性能方面很重要:它们更具可重用性和可测试性,并且可以更容易地理解正在执行的操作以及输入和输出数据是什么。 Julia风格指南还建议 编写函数,而不仅仅是脚本

函数应该接受参数,而不是直接使用全局变量(见下一段)。

避免无类型的全局变量

无类型全局变量的值可以随时更改,这可能导致其类型的更改。 因此,编译器很难优化使用全局变量的代码。 这也适用于值为类型的变量,即在全局级别键入别名。 变量应该是局部的,或者在可能的情况下作为参数传递给函数。

全局名称通常是常量,如果以这种方式声明它们,则可以显着提高性能。:

const DEFAULT_VAL = 0

如果已知全局变量的类型不会改变。, 应该注释

无类型全局变量的使用可以通过在使用时注释它们的类型来优化。:

global x = rand(1000)

函数loop_over_global()
    s=0.0
    对于x中的i::Vector{Float64}
        s+=i
    结束
    返回s
结束

从样式的角度来看,最好将参数传递给函数。 这提高了代码的可重用性,并允许更好地理解输入和输出数据。

REPL中的所有代码都是在全局范围内计算的,因此在顶层定义和设置的变量将是*global*。 在模块内的顶级作用域中定义的变量也是全局的。

在REPL会话中,这段代码是:

julia> x = 1.0

相当于以下内容:

julia> global x = 1.0

因此,上面描述的所有与性能相关的特征都是适用的。

使用宏测量性能 '@time'并注意内存分配

宏对于衡量性能很有用。 '@time'。 在这里,我们将用一个全局变量重复上面的例子,但这次我们将删除类型注释。:

julia> x = rand(1000);

julia> function sum_global()
           s = 0.0
           for i in x
               s += i
           end
           return s
       end;

julia> @time sum_global()
  0.011539 seconds (9.08 k allocations: 373.386 KiB, 98.69% compilation time)
523.0007221951678

julia> @time sum_global()
  0.000091 seconds (3.49 k allocations: 70.156 KiB)
523.0007221951678

在第一次调用('@time sum_global()')时,该函数被编译。 (如果会话中有宏 '@time'还没有被使用,计时所需的功能也会被编译。)你不应该认真对待这个执行的结果。 注意,对于第二次执行,除了时间之外,还报告已经分配了显着量的存储器。 在这种情况下,简单地计算64位浮点数的向量中所有元素的总和,因此不需要分配内存(在堆中)。

应该澄清的是,宏'@time’正好在_period_中报告内存的分配。 通常,对于可变对象或创建或扩大可变大小的容器(例如,ArrayDict,字符串或类型不稳定的对象,其类型仅在运行时已知),都需要这样的分配。 分配(或解除分配)此类内存块可能需要对libc进行资源密集型函数调用(例如,c中的malloc)并由垃圾收集器进行跟踪。 相反,存储数字(除了bignum),元组和不可变结构(struct)等不可变值需要少得多的资源(例如,它们可以存储在堆栈上或CPU寄存器内存中),因此在为它们分配内存时不必

意外的内存分配几乎总是某种代码问题的标志,通常与类型稳定性或许多小型临时数组的创建有关。 因此,即使不考虑内存消耗,也存在为函数生成的代码远非最优的高概率。 注意这些症状并遵循以下建议。

在这种特殊情况下,由于使用不稳定的全局变量`x’而分配内存。 如果您改为将`x`作为参数传递给函数,则不会分配更多内存(由于宏`@time`在全局区域中执行,因此分配的一些内存较低)。 第一次调用后,此选项会快得多。:

julia> x = rand(1000);

julia> function sum_arg(x)
           s = 0.0
           for i in x
               s += i
           end
           return s
       end;

julia> @time sum_arg(x)
  0.007551 seconds (3.98 k allocations: 200.548 KiB, 99.77% compilation time)
523.0007221951678

julia> @time sum_arg(x)
  0.000006 seconds (1 allocation: 16 bytes)
523.0007221951678

在输出中分配1是由于宏'@time’本身是在全局区域中执行的。 如果时间计入函数本身,那么根本不会分配内存。:

julia> time_sum(x) = @time sum_arg(x);

julia> time_sum(x)
  0.000002 seconds
523.0007221951678

在某些情况下,函数可能需要在操作期间分配内存,这会使图片复杂化。 在这种情况下,您可以使用以下选项之一 工具来诊断问题,或编写一个版本的函数,其中内存分配和算法方面彼此隔离(参见部分 输出数据的内存预分配)。

对于更彻底的性能测试,您可以使用包https://github.com/JuliaCI/BenchmarkTools.jl [基准工具。jl],其中除其他外,多次评估函数以减少随机因子的影响。

工具

Julia语言及其软件包生态系统提供了可以帮助诊断问题和提高代码性能的工具。:

  • 分析允许您测量执行的代码的性能,并确定是瓶颈的行。 在复杂的项目中,包https://github.com/timholy/ProfileView.jl [ProfileView]可以帮助可视化分析的结果。

  • 包裹https://github.com/aviatesk/JET.jl [Traceur]可以帮助识别常见的代码性能问题。

  • 意外的大量分配的内存根据宏的输出 '@时间', '@allocated'或探查器(调用垃圾回收例程时)提示代码可能存在的问题。 如果没有其他原因导致这种过度分配,首先,我们可以假设类型有问题。 您还可以使用`--track-allocation=user`选项运行Julia并检查收到的'*。mem’文件以找出内存分配发生的位置。 请参阅部分 内存分配分析

  • 宏'@code_warntype’创建代码的表示形式,可帮助您查找导致类型歧义的表达式。 请参阅宏部分。 '@code_warntype'

避免使用抽象类型参数的容器。

在使用参数化类型(包括数组)时,最好尽可能避免使用抽象类型进行参数化。

考虑以下代码:

julia> a = Real[]
Real[]

julia> push!(a, 1); push!(a, 2.0); push!(a, π)
3-element Vector{Real}:
 1
 2.0
 π = 3.1415926535897...

由于`a’是抽象类型的数组 'Real`,应该可以在其中存储`Real’的任何值。 由于"真实"对象可以是任何大小和结构,变量"a"必须由指向内存中单个"真实"对象的指针数组表示。 但是,如果只有一种类型的数字可以存储在`a`中,例如 `Float64',存储效率会提高:

julia> a = Float64[]
Float64[]

julia> push!(a, 1); push!(a, 2.0); push!(a,  π)
3-element Vector{Float64}:
 1.0
 2.0
 3.141592653589793

当为变量`a’分配数字时,它们现在将被转换为`Float64`类型,并且`a`将被存储为可以有效操作的64位浮点值的连续块。

如果您无法避免使用具有抽象值类型的容器,有时最好使用`Any`类型对它们进行参数化,以便在运行时不检查这些类型。 例如,'IdDict{Any, Any}"会比"IdDict"跑得更快{Type, Vector}`.

另请参阅本节中的讨论 参数类型

类型声明

在许多具有可选类型声明的语言中,添加声明是提高代码性能的主要方法。 在朱莉娅,情况并非如此。 在Julia中,编译器通常知道函数、局部变量和表达式的所有参数的类型。 但是,在一些特殊情况下,广告可能很有用。

避免使用抽象类型的字段。

声明字段时,不需要指定它们的类型。:

julia> struct MyAmbiguousType
           a
       end

结果,`a’可以是任何类型。 这通常很有用,但有一个缺点:对于像`MyAmbiguousType`这样的对象,编译器将无法生成高性能代码。 原因是,在确定如何构建代码时,编译器基于对象的类型,而不是基于它们的值。 不幸的是,从`MyAmbiguousType`类型中学习的东西并不多。:

julia> b = MyAmbiguousType("Hello")
MyAmbiguousType("Hello")

julia> c = MyAmbiguousType(17)
MyAmbiguousType(17)

julia> typeof(b)
MyAmbiguousType

julia> typeof(c)
MyAmbiguousType

值`b`和`c`是相同类型的,但它们在内存中的基本表示方式非常不同。 即使在"a"字段中只存储了数值,但由于类型的表示 'UInt8'`Float64'在内存中变化,并且还遵循它们必须由处理器使用不同的指令处理。 由于类型不包含必要的信息,因此必须在运行时做出决定。 这降低了生产率。

为了改善这种情况,您可以声明类型`a`。 在这里,我们正在考虑一种情况,即`a’可以有几种类型中的任何一种。 在这种情况下,显而易见的解决方案是使用参数。 例如:

julia> mutable struct MyType{T<:AbstractFloat}
           a::T
       end

这个选项比

julia> mutable struct MyStillAmbiguousType
           a::AbstractFloat
       end

因为在第一种情况下,类型’a’是根据包装器对象的类型确定的。 例如:

julia> m = MyType(3.2)
MyType{Float64}(3.2)

julia> t = MyStillAmbiguousType(3.2)
MyStillAmbiguousType(3.2)

朱莉娅>类型(m)
[医]类型{Float64}

朱莉娅>类型(t)
[医]神秘型

字段类型’a`可以很容易地根据类型`m`确定,但不是类型’t'。 实际上,在’t`中,'a’字段的类型可能会改变。:

julia> typeof(t.a)
Float64

julia> t.a = 4.5f0
4.5f0

julia> typeof(t.a)
Float32

相反,在`m’创建之后,`m.a`的类型不能改变。:

julia> m.a = 4.5f0
4.5f0

julia> typeof(m.a)
Float64

类型’m.a`是从’m’派生的,再加上这种类型不能在函数内部改变的事实,允许编译器为诸如`m`的对象生成优化的代码,而不是`t'。

当然,只有当用特定类型创建`m`时,所有这一切才是真实的。 如果您使用抽象类型显式创建它,则情况会发生变化。:

julia> m = MyType{AbstractFloat}(3.2)
MyType{AbstractFloat}(3.2)

julia> typeof(m.a)
Float64

julia> m.a = 4.5f0
4.5f0

julia> typeof(m.a)
Float32

在实践中,此类对象的行为方式始终与’MyStillAmbiguousType’类型的对象相同。

比较为一个简单的函数生成的代码量是很有启发性的。

func(m::MyType) = m.a+1

在帮助下

code_llvm(func, Tuple{MyType{Float64}})
code_llvm(func, Tuple{MyType{AbstractFloat}})

由于长度很长,这里没有给出结果,但你可以自己尝试一下。 由于在第一种情况下完全指定了类型,因此编译器不需要创建代码来在运行时解析类型。 这使得代码更加简洁和快速。

此外,应该记住,不是完全参数化的类型表现得像抽象类型。 例如,虽然完全指定的类型是’Array{T,n}'是特定的,并且类型’Array’本身不是没有参数的。:

julia> !isconcretetype(Array), !isabstracttype(Array), isstructtype(Array), !isconcretetype(Array{Int}), isconcretetype(Array{Int,1})
(true, true, true, true, true)

在这种情况下,最好不要使用字段`a::Array`声明`MyType`,而是将字段声明为`a::Array'。{T,N}'或作为’a::A`,其中 {T,N} 或者`A’是`MyType’的参数。

当结构的字段是函数或更一般地说是可调用对象时,前面的建议特别有用。 将结构定义如下是很诱人的:

struct MyCallableWrapper
    f::Function
end

但是由于’Function’是一个抽象类型,所以每个调用都是一个’包装器。f’将由于对字段`f`的访问类型的不稳定性而需要动态调度。 相反,您应该编写如下内容:

struct MyCallableWrapper{F}
    f::F
end

具有几乎相同的行为,但性能要快得多(因为消除了类型不稳定性)。 请注意,使用`F<:Function’可选:这意味着没有’Function’子类型的可调用对象也允许用于’f’字段。

避免使用抽象容器的字段。

同样的建议适用于容器类型。:

julia> struct MySimpleContainer{A<:AbstractVector}
           a::A
       end

julia> struct MyAmbiguousContainer{T}
           a::AbstractVector{T}
       end

julia> struct MyAlsoAmbiguousContainer
           a::Array
       end

例如:

julia> c = MySimpleContainer(1:3);

julia> typeof(c)
MySimpleContainer{UnitRange{Int64}}

julia> c = MySimpleContainer([1:3;]);

julia> typeof(c)
MySimpleContainer{Vector{Int64}}

julia> b = MyAmbiguousContainer(1:3);

julia> typeof(b)
MyAmbiguousContainer{Int64}

julia> b = MyAmbiguousContainer([1:3;]);

julia> typeof(b)
MyAmbiguousContainer{Int64}

julia> d = MyAlsoAmbiguousContainer(1:3);

julia> typeof(d), typeof(d.a)
(MyAlsoAmbiguousContainer, Vector{Int64})

julia> d = MyAlsoAmbiguousContainer(1:1.0:3);

julia> typeof(d), typeof(d.a)
(MyAlsoAmbiguousContainer, Vector{Float64})

对于MySimpleContainer来说,对象完全由其类型和参数决定,因此编译器可以生成优化的函数。 在大多数情况下,这可能就足够了。

虽然编译器现在做得很好,但有时您可能需要在代码中实现不同的操作,具体取决于`a`元素的类型。 通常,执行此操作的最佳方法是将特定操作(在本例中为`foo`)包含在单独的函数中。:

julia> function sumfoo(c::MySimpleContainer)
           s = 0
           for x in c.a
               s += foo(x)
           end
           s
       end
sumfoo (generic function with 1 method)

julia> foo(x::Integer) = x
foo (generic function with 1 method)

julia> foo(x::AbstractFloat) = round(x)
foo (generic function with 2 methods)

这简化了代码,并允许编译器始终生成优化的代码。

但是,有时您可能需要为不同的元素类型或`MySimpleContainer`中的`a`字段的`AbstractVector’类型声明不同版本的外部函数。 它可以这样做:

julia> function myfunc(c::MySimpleContainer{<:AbstractArray{<:Integer}})
           return c.a[1]+1
       end
myfunc (generic function with 1 method)

julia> function myfunc(c::MySimpleContainer{<:AbstractArray{<:AbstractFloat}})
           return c.a[1]+2
       end
myfunc (generic function with 2 methods)

julia> function myfunc(c::MySimpleContainer{Vector{T}}) where T <: Integer
           return c.a[1]+3
       end
myfunc (generic function with 3 methods)
julia> myfunc(MySimpleContainer(1:3))
2

julia> myfunc(MySimpleContainer(1.0:3))
3.0

julia> myfunc(MySimpleContainer([1:3;]))
4

注释来自非类型化对象的值。

使用可以包含任何类型值的数据结构通常很方便(类型为`Array的数组{Any}`). 但是,如果在使用这样的结构时确切地知道元素的类型,则通知编译器将是有用的。:

function foo(a::Array{Any,1})
    x = a[1]::Int32
    b = x+1
    ...
end

这里我们知道数组’a`的第一个元素将是类型 'Int32'。 这种注释的另一个优点是,如果值具有所需类型以外的类型,则在执行期间会发生错误。 也许这将有助于在代码早期识别其他错误。

如果元素`a[1]的类型不完全知道,`x`可以这样声明:`x=convert(Int32,A[1])::Int32。 通过使用函数 `convert'元素’a[1]`可以是转换为`Int32`的任何对象(例如,`UInt8')。 由于不太严格的类型要求,这增加了代码的通用性。 请注意,在此上下文中,'convert’函数本身需要类型注释来实现类型稳定性。 原因是编译器不能推断函数返回值的类型,即使是"转换",如果它的任何参数的类型是未知的。

如果类型是抽象的或在运行时构造的,类型注释不会提高(甚至可能降低)性能。 这是因为编译器不能使用注释来专门化后续代码,并且类型检查本身需要时间。 例如,在以下代码中:

function nr(a, prec)
    ctype = prec == 32 ? Float32 : Float64
    b = Complex{ctype}(a)
    c = (b + 1.0f0)::Complex{ctype}
    abs(c)
end

注释’c’对性能有负面影响。 要使用在运行时构造的类型编写高性能代码,请使用下面描述的代码。 接受函数之间的障碍并在内核函数的参数类型中包含构造的类型,以便内核执行的操作由编译器适当地专业化。 例如,在下面的代码片段中,只要变量`b`被创建,它就可以传递给另一个函数`k`,即内核。 例如,如果在函数’k`中,参数’b’被声明为具有类型'Complex{T}`,其中’T’是一个类型参数,那么`k’内部赋值运算符中的类型注释如下所示:

c = (b + 1.0f0)::Complex{T}

它不会降低性能(但也不会增加它),因为编译器可以在编译函数`k`期间确定`c`的类型。

请记住,当Julia的专业化不是自动完成的。

通常,参数类型的参数不是 specialize在三种特定情况下自动在Julia中:'Type`,`Function’和’Vararg'。 当一个参数在一个方法中使用时,特化总是在Julia中执行,而不仅仅是传递给另一个函数。 这通常对运行时性能和 提高编译器性能。 如果在您的情况下,事实证明对性能有影响,则可以通过向方法声明添加类型参数来启动专业化。 以下是一些例子:

在这种情况下,不执行专业化。:

function f_type(t)  # или t::Type
    x = ones(t, 10)
    return sum(map(sin, x))
end

在这种情况下,它是生产出来的:

function g_type(t::Type{T}) where T
    x = ones(T, 10)
    return sum(map(sin, x))
end

在这些情况下,不执行专业化。:

f_func(f, num) = ntuple(f, div(num, 2))
g_func(g::Function, num) = ntuple(g, div(num, 2))

在这种情况下,它是生产出来的:

h_func(h::H, num) where {H} = ntuple(h, div(num, 2))

在这种情况下,不执行专业化。:

f_vararg(x::Int...) = tuple(x...)

在这种情况下,它是生产出来的:

g_vararg(x::Vararg{Int, N}) where {N} = tuple(x...)

要强制特化,只输入一个类型参数就足够了,即使其他类型不受限制。 例如,在这种情况下也执行特化(如果不是所有参数都是相同类型,则此方法可能很有用):

h_vararg(x::Vararg{Any, N}) where {N} = tuple(x...)

请注意,宏 '@code_typed'和其他类似的工具总是显示专门的代码,即使在Julia中这个方法调用通常不是专门的。 如果您想了解在更改参数类型时是否创建了特化,即是否'Base。专业化(@which f(。..))包含需要研究的相应参数的'特化 方法的内部结构

将函数拆分为多个定义

如果函数被实现为几个小定义,编译器可以直接调用最合适的代码,甚至嵌入它。

下面是一个"复合函数"的例子,它实际上应该被实现为多个定义。:

using LinearAlgebra

function mynorm(A)
    if isa(A, Vector)
        return sqrt(real(dot(A,A)))
    elseif isa(A, Matrix)
        return maximum(svdvals(A))
    else
        error("mynorm: invalid argument")
    end
end

此选项将更加简洁和有效。:

mynorm(x::Vector) = sqrt(real(dot(x, x)))
mynorm(A::Matrix) = maximum(svdvals(A))

但是,应该注意的是,编译器在优化代码中的死端分支方面非常有效,就像`mynorm’的例子一样。

编写类型稳定的函数。

希望函数尽可能始终返回相同类型的值。 考虑以下定义:

pos(x) = x < 0 ? 0 : x

虽然看起来相当无害,但问题是`0`是一个整数(`Int`类型),而`x`可以是任何类型。 因此,根据`x’的值,此函数可以返回两种类型之一的值。 这种行为是可接受的,并且在某些情况下可能是期望的。 但是,情况可以很容易地固定如下:

pos(x) = x < 0 ? zero(x) : x

还有功能 'oneunit'和更一般的函数 oftype(x,y),返回参数`y`,转换为类型’x'。

尽量不要改变变量的类型。

类似的"类型稳定性"问题与在函数内部重用的变量有关。:

function foo()
    x = 1
    for i = 1:10
        x /= rand()
    end
    return x
end

局部变量’x’起初是一个整数,但在循环的一次迭代后它变成了浮点数(作为应用运算符的结果 /). 因此,编译器更难以优化循环体。 有几种方法可以解决这个问题。:

  • 用值`x=1.0`初始化`x`;

  • 明确声明`x’的类型为’x::Float64=1`;

  • 使用显式转换’x=oneunit(Float64)`;

  • 在第一次迭代中使用初始化`x=1/rand()执行循环`for i=2:10

分离核心功能(即功能之间的使用障碍)

许多函数都是按照一定的模式构建的:首先做一些准备工作,然后重复许多迭代来执行基本计算。 如果可能的话,最好将这些基本计算分离成单独的函数。 例如,下面的虚构函数返回一个随机类型的数组:

julia> function strange_twos(n)
           a = Vector{rand(Bool) ? Int64 : Float64}(undef, n)
           for i = 1:n
               a[i] = 2
           end
           return a
       end;

julia> strange_twos(3)
3-element Vector{Int64}:
 2
 2
 2

这样写是正确的:

julia> function fill_twos!(a)
           for i = eachindex(a)
               a[i] = 2
           end
       end;

julia> function strange_twos(n)
           a = Vector{rand(Bool) ? Int64 : Float64}(undef, n)
           fill_twos!(a)
           return a
       end;

julia> strange_twos(3)
3-element Vector{Int64}:
 2
 2
 2

Julia编译器在考虑函数边界处的参数类型的情况下专门处理代码,因此,在初始实现中,类型`a’在循环执行期间是未知的(因为它是随机选择的)。 第二个版本可能运行得更快,因为内部循环可以重新编译为’fill_twos!'用于不同类型的`a'。

第二种形式在样式方面也更正确,并使代码更易于重用。

此模板在基础Julia模块中重复使用。 例如,您可以熟悉函数`vcat`和`hcat’在https://github.com/JuliaLang/julia/blob/40fe264f4ffaa29b749bcf42239a89abdcbba846/base/abstractarray.jl#L1205-L1206[abstractarray.jl],以及与功能 '填充!,它可以用来代替编写你自己的实现’fill_twos!.

在处理未定义类型的数据时使用`strange_twos’等函数,例如,从可能包含整数,浮点数,字符串或其他内容的输入文件加载。

以值为参数的类型

假设您想在每个轴上创建一个大小为3的`N`维数组。 这样的数组可以创建如下:

julia> A = fill(5.0, (3, 3))
3×3 Matrix{Float64}:
 5.0  5.0  5.0
 5.0  5.0  5.0
 5.0  5.0  5.0

这种方法非常有效:编译器可以确定`A`是’数组'。{Float64,2}',因为它知道初始化值的类型('5.0::Float64')和维度(`(3,3)::NTuple{2,Int}因此,编译器可以生成非常有效的代码,以便在同一函数中进一步使用数组`A'。

但是现在让我们假设你需要编写一个创建3×3×的函数。.. 任意维度的数组。 下面的实现建议自己:

julia> function array3(fillval, N)
           fill(fillval, ntuple(d->3, N))
       end
array3 (generic function with 1 method)

julia> array3(5.0, 2)
3×3 Matrix{Float64}:
 5.0  5.0  5.0
 5.0  5.0  5.0
 5.0  5.0  5.0

它可以工作,但是(正如您在`@code_warntype array3(5.0,2)的帮助下可以看到的那样)问题是输出类型无法推断:参数`N`是Int类型值,类型推断引擎无法预测其值。 这意味着使用此函数结果的代码必须是保守的:每次访问`A`时都必须检查类型。 此代码将运行非常缓慢。

解决此类问题的非常有效的方法之一是使用 消除功能之间的障碍。 然而,在某些情况下,可能需要完全消除类型不稳定性。 一种方法可能是将维度作为参数传递,例如通过使用'Val{T}()`(见部分 类型-值):

julia> function array3(fillval, ::Val{N}) where N
           fill(fillval, ntuple(d->3, Val(N)))
       end
array3 (generic function with 1 method)

julia> array3(5.0, Val(2))
3×3 Matrix{Float64}:
 5.0  5.0  5.0
 5.0  5.0  5.0
 5.0  5.0  5.0

Julia有一个特殊版本的`ntuple',它接受一个`Val’的实例{::Int}`作为第二个参数;通过传递`N’作为类型参数,您将其"值"告诉编译器。 因此,此版本的array3允许编译器预测返回类型。

然而,使用这种技术可能会隐藏意想不到的微妙之处。 例如,从像下面这样的函数调用`array3’将是无用的:

function call_array3(fillval, n)
    A = array3(fillval, Val(n))
end

这里出现了同样的问题:编译器无法确定’n`,因此它不知道`Val(n)'的类型。 不正确使用"Val"通常会导致性能下降。 (只有当`Val`与函数之间的屏障正确组合时,才建议使用上述代码模板,这使得核心函数更有效率。)

下面是正确使用`Val`的例子。

function filter3(A::AbstractArray{T,N}) where {T,N}
    kernel = array3(1, Val(N))
    filter(A, kernel)
end

在这个例子中,'N’作为参数传递,因此编译器知道它的"值"。 实际上,Val(T)`只有在`T`是硬编码的,是字面值(`Val(3)),或者已经在类型范围中指定的情况下才起作用。

<无翻译>

不正确使用多个调度的风险(以值为参数的类型主题的延续)

在了解了多次调度的优点之后,程序员可以忘乎所以,开始在连续的所有情况下应用它。 例如,它可以使用以下结构来存储信息:

struct Car{Make, Model}
    year::Int
    ...more fields...
end

然后执行对象的调度’Car{:Honda,:Accord}(年,args。..)`.

如果满足以下条件之一,这可能是可取的。

  • 每个"Car"对象都需要复杂的处理器处理,如果在编译时知道"Make"和"Model",并且不同的"Make"或"Model"的总数不会太大,则效率会高得多。

  • 有相同类型的"汽车"对象的同质列表,可以存储在"数组"数组中。{Car{:Honda,:Accord},N}`。

如果后者是真的,那么处理这样一个同质数组的函数可以被有效地专门化:每个元素的类型是Julia预先知道的(容器中的所有对象都有相同的特定类型),因此在编译函数时,Julia可以确定合适的方法调用(因此这将不需要在运行时完成),从而生成有效的代码来处理整个列表。

如果不满足这些条件,最有可能的是,将没有任何好处。 而且,这种方式造成的"类型组合爆炸"会产生相反的效果。 如果类型’items[i+1]`与类型`item[i]`不同,Julia环境将不得不在运行时确定类型,在方法表中搜索适当的方法,确定适当的方法(通过交叉类型),确定其JIT编译是否已 实际上,您正在指示类型系统和JIT编译机制在代码中执行类似于分支运算符或字典搜索的操作。

一些比较类型调度、字典搜索和分支运算符的测试结果可以在https://groups.google.com/forum /#!msg/julia-users/jUMu9A3QKQQ/qjgVWr7vAwAJ[论坛讨论]。

也许编译时效应比运行时效应更严重:Julia为每个单独的"Car"对象编译专门的函数。{Make, Model}`. 如果有数百或数千种类型,那么对于每个接受这样一个对象作为参数的函数(它自己的自定义函数`get_year',通用函数`push!'从基础Julia模块或任何其他),将编译数百或数千个变体。 它们中的每一个都增加了编译代码的缓存大小,方法的内部列表的长度,等等。 对作为参数的值的过度热情很容易导致资源枯竭。

按照数组在内存中的放置顺序(按列)访问数组

Julia多维数组是按列存储在内存中的,即逐列放置。 这可以使用`vec`函数或语法构造`[:]'来检查,如下所示(请注意,数组元素遵循`[1 3 2 4]'的顺序,而不是`[1 2 3 4]`).

julia> x = [1 2; 3 4]
2×2 Matrix{Int64}:
 1  2
 3  4

julia> x[:]
4-element Vector{Int64}:
 1
 3
 2
 4

这种数组排序约定在许多语言中被接受,包括Fortran,Matlab,R和其他语言。 基于列的内存分配的另一种选择是基于行的;这种约定在C和Python(numpy)等中被接受。 数组在内存中的放置顺序可能会在遍历它们时对性能产生严重影响。 记住一个简单的规则:如果一个数组以列的形式存储在内存中,第一个索引的变化最快。 这意味着,如果最深嵌套循环中使用的索引是切片表达式中的第一个,则迭代会更快。 请记住,当使用':'索引数组时,隐式循环是通过对某个维度中的每个元素的备用访问来执行的。 例如,列的检索速度比行快。

考虑以下人为示例。 假设我们需要编写一个接受对象的函数 `Vector'并返回一个方阵 'Matrix',其中的行或列填充输入向量的副本。 比方说,行或列是否会用这些副本填充对我们来说并不重要(也许可以在代码的另一部分中配置相应的行为)。 至少有四种方法可以实现这一点(除了对内置函数的推荐调用之外 `重复'):

function copy_cols(x::Vector{T}) where T
    inds = axes(x, 1)
    out = similar(Array{T}, inds, inds)
    for i = inds
        out[:, i] = x
    end
    return out
end

function copy_rows(x::Vector{T}) where T
    inds = axes(x, 1)
    out = similar(Array{T}, inds, inds)
    for i = inds
        out[i, :] = x
    end
    return out
end

function copy_col_row(x::Vector{T}) where T
    inds = axes(x, 1)
    out = similar(Array{T}, inds, inds)
    for col = inds, row = inds
        out[row, col] = x[row]
    end
    return out
end

function copy_row_col(x::Vector{T}) where T
    inds = axes(x, 1)
    out = similar(Array{T}, inds, inds)
    for row = inds, col = inds
        out[row, col] = x[col]
    end
    return out
end

现在,我们将通过输入大小为"10000"的向量来测量这些函数中的每个函数的执行时间:

julia> x = randn(10000);

julia> fmt(f) = println(rpad(string(f)*": ", 14, ' '), @elapsed f(x))

julia> map(fmt, [copy_cols, copy_rows, copy_col_row, copy_row_col]);
copy_cols:    0.331706323
copy_rows:    1.799009911
copy_col_row: 0.415630047
copy_row_col: 1.721531501

请注意,'copy_cols’函数比`copy_rows’快得多。 这是相当预期的,因为`copy_cols`函数考虑了`Matrix`对象按列放置在内存中的顺序,并一次将其填充在一列中。 此外,'copy_col_row`函数比’copy_row_col’快得多,因为它遵循切片表达式中的第一个元素应该在最深的嵌套循环中使用的规则。

为输出数据预分配内存

如果一个函数返回一个’数组’或另一个复杂类型的对象,它可能需要分配内存。 不幸的是,内存分配和反向操作,垃圾回收,往往是严重的瓶颈。

有时,您可以通过为输出数据预先分配内存来避免每次调用函数时都必须分配内存。 让我们来看一个最简单的例子。

julia> function xinc(x)
           return [x, x+1, x+2]
       end;

julia>函数loopinc()
           y=0
           对于i=1:10^7
               ret=xinc(i)
               y+=ret[2]
           结束
           返回y
       结束;

julia> function xinc!(ret::AbstractVector{T}, x::T) where T
           ret[1] = x
           ret[2] = x+1
           ret[3] = x+2
           nothing
       end;

julia> function loopinc_prealloc()
           ret = Vector{Int}(undef, 3)
           y = 0
           for i = 1:10^7
               xinc!(ret, i)
               y += ret[2]
           end
           return y
       end;

时间测量结果:

julia> @time loopinc()
  0.529894 seconds (40.00 M allocations: 1.490 GiB, 12.14% gc time)
50000015000000

julia> @time loopinc_prealloc()
  0.030850 seconds (6 allocations: 288 bytes)
50000015000000

预分配内存还有其他优点。 例如,调用者可以控制算法的输出类型。 在上面的例子中,如果需要,你可以传递`SubArray`代替 '数组'

滥用内存的预分配会使代码不那么优雅,因此可能需要评估其使用的适当性并衡量性能。 但是,对于"矢量化"函数(逐个元素执行),您可以使用方便的语法’x.=f.(y’进行具有没有临时数组的组合循环的就地操作(请参阅上一节 矢量化函数的点语法)。

使用’MutableArithmetics’对可变算术类型的分配进行额外控制`

一些亚型 'Number',如 'BigInt''BigFloat',可以实现为类型 '可变结构'或具有可变组件。 在这种情况下,Julia`Base`中的算术接口通常选择便利性而不是效率,因此最简单的使用可能会导致次优的性能。 包抽象https://juliahub.com/ui/Packages/General/MutableArithmetics 另一方面,['MutableArithmetics']允许您使用此类类型的可变性来编写分配必要卷的快速代码。 'MutableArithmetics’也可用于在必要时显式复制可变算术类型的值。 'MutableArithmetics’是一个自定义包,与Julia项目无关。

更多要点:结合矢量化操作

朱莉娅有一个特别的 点语法,它将任何标量函数转换为"矢量化"函数调用,并将任何运算符转换为"矢量化"运算符。 与此同时,它有一个特殊的属性:嵌套的"点调用"在语法级别组合成一个循环,而不为临时数组分配内存。 时使用`。='和类似的赋值运算符,结果也可以存储在预先存储的数组中(见上文)。

在线性代数的上下文中,这意味着虽然定义了`向量+向量`和`向量*标量’等操作,但使用`向量’可能会更好。+向量’和’向量。*标量`,因为产生的循环可以与相邻计算相结合。 例如,考虑两个函数:

julia> f(x) = 3x.^2 + 4x + 7x.^3;

julia> fdot(x) = @. 3x^2 + 4x + 7x^3; # эквивалентно 3 .* x.^2 .+ 4 .* x .+ 7 .* x.^3

函数’f’和’fdot’执行相同的计算。 但是,函数’fdot'(使用宏定义 `@.')应用于数组时执行的速度要快得多:

julia> x = rand(10^6);

julia> @time f(x);
  0.019049 seconds (16 allocations: 45.777 MiB, 18.59% gc time)

julia> @time fdot(x);
  0.002790 seconds (6 allocations: 7.630 MiB)

julia> @time f.(x);
  0.002626 seconds (8 allocations: 7.630 MiB)

也就是说,'fdot(x)比`f(x)`快十倍,分配的内存少六倍,因为`f(x)`中的*+'的每个操作都为新的临时数组分配内存,并且在单独的循环中执行。 在这个例子中,函数`f.(x)`的速度与函数`fdot(x)`相同,但在许多情况下,简单地在表达式中添加几个点比为每个矢量化操作定义一个单独的函数更方便。

少点:分离一些中间翻译

上述点循环的组合使您可以用简洁和惯用的代码表达高性能操作。 但是,重要的是要记住,组合操作将在翻译的每次迭代中计算。 这意味着在某些情况下,特别是在存在复合或多维翻译的情况下,具有点调用的表达式可能会比预期的更多次评估函数。 例如,我们要构建一个随机矩阵,其行具有等于1的欧几里德范数。 我们可以这样写:

julia> x = rand(1000, 1000);

julia> d = sum(abs2, x; dims=2);

julia> @time x ./= sqrt.(d);
  0.002049 seconds (4 allocations: 96 bytes)

它会奏效的。 但是,这样的表达式实际上会为字符串`x[i,:]中的每个元素重新计算`sqrt(d[i]),这意味着计算的平方根比必要的要多得多。 要确切地找出哪些索引将被迭代,您可以调用’Broadcast。combine_axes’用于组合表达式的参数。 将返回一个范围元组,其条目对应于迭代的轴。 这些范围的长度的乘积将是对组合操作的调用总数。

由此可知,如果平移表达式的某些分量沿轴是恒定的,例如,在前面的示例中第二维中的`sqrt`,那么可以通过强制"分离"这些分量来提高性能,即预先分配平移操 其中一些可能的方法使用临时变量,将点表达式的组件包含在"标识`中,或者使用等效的内部矢量化(但不是组合)函数。

julia> @time let s = sqrt.(d); x ./= s end;
  0.000809 seconds (5 allocations: 8.031 KiB)

julia> @time x ./= identity(sqrt.(d));
  0.000608 seconds (5 allocations: 8.031 KiB)

julia> @time x ./= map(sqrt, d);
  0.000611 seconds (4 allocations: 8.016 KiB)

在每个选项中,通过突出显示实现了大约三倍的加速度。 对于大的广播对象,这样的加速度可以渐近地非常大。

对横截面使用视图

在Julia中,数组切片表达式,如`array[1:5,:],创建数据的副本(但如果在赋值的左侧使用:+array[1:5,:]=则不会。..+`代替数组`array’的相应部分执行赋值)。 如果使用切片执行大量操作,则从性能角度来看,这可能是有利的,因为使用小型连续副本比按索引访问源数组更有效。 另一方面,如果用切片执行少量简单操作,则内存分配和复制的成本可能是显着的。

另一种选择是创建数组的"表示",即数组对象(SubArray),该对象在不创建副本的情况下引用原始数组的数据。 (当写入视图时,原始数组中的数据也会发生变化。)您可以通过调用函数为单个切片执行此操作 'view',或者更简单地说,对于整个表达式或代码块,指定 `@views'在表达式之前。 例如:

julia> fcopy(x) = sum(x[2:end-1]);

julia> @views fview(x) = sum(x[2:end-1]);

julia> x = rand(10^6);

julia> @time fcopy(x);
  0.003051 seconds (3 allocations: 7.629 MB)

julia> @time fview(x);
  0.001020 seconds (1 allocation: 16 bytes)

请注意,函数的`fview’版本速度快三倍,分配的内存更少。

复制数据并不总是一件坏事

数组作为连续块存储在内存中,这使得可以矢量化CPU并减少由于缓存而导致的内存访问次数。 出于同样的原因,建议通过列访问数组(见上文)。 随机存取模式和脱节表示会由于不一致的内存访问而严重减慢数组操作。

在重新访问之前将随机访问的数据复制到连续数组中会导致显着的速度提升,如下例所示。 在这种情况下,在随机索引处访问矩阵元素,然后相乘。 复制到常规数组会加快乘法速度,尽管会产生复制和分配内存的成本。

julia> using Random

julia> A = randn(3000, 3000);

julia> x = randn(2000);

julia> inds = shuffle(1:3000)[1:2000];

julia> function iterated_neural_network(A, x, depth)
           for _ in 1:depth
               x .= max.(0, A * x)
           end
           argmax(x)
       end

julia> @time iterated_neural_network(view(A, inds, inds), x, 10)
  0.324903 seconds (12 allocations: 157.562 KiB)
1569

julia> @time iterated_neural_network(A[inds, inds], x, 10)
  0.054576 seconds (13 allocations: 30.671 MiB, 13.33% gc time)
1569

如果有足够的内存,将表示复制到数组的成本超过由于连续数组中的重复乘法而导致的速度增益所抵消。

使用StaticArrays。jl用于具有固定大小向量和矩阵的小运算

如果计算涉及许多固定大小的小(`<100`元素)数组(在执行之前已知),那么您可以尝试使用https://github.com/JuliaArrays/StaticArrays.jl [StaticArrays。jl包]。 它允许您以不需要堆中不必要的内存分配的方式表示此类数组,并且编译器将能够在考虑数组大小的情况下专门化代码,例如,通过完全部署向量操作(消除循环)并将元素存储在CPU寄存器中。

例如,在二维几何计算中,可能存在许多具有双分量向量的操作。 通过使用StaticArrays包中的’SVector’类型。方便的向量表示法和诸如`norm(3v-w)的操作对于jl来说变得可用于向量`v`和`w,编译器可以将代码扩展为相当于`@inbounds hypot(3v[1]-w[1],3v[2]-w[2])`的最小计算。

避免i/O的字符串插值

将数据写入文件(或另一个I/O设备)时,构建中间行需要额外的成本。 而不是:

println(file, "$a $b")

用这个:

println(file, a, " ", b)

在代码的第一个版本中,首先构造字符串,然后写入文件,而在第二个版本中,值直接写入文件。 另外,请注意,在某些情况下,字符串插值代码可能不太可读。 比较一下:

println(file, "$(f(a))$(f(b))")

有了这个选项:

println(file, f(a), f(b))

在并行执行期间优化网络I/O

在并行模式下执行远程函数时,如下代码:

using Distributed

responses = Vector{Any}(undef, nworkers())
@sync begin
    for (idx, pid) in enumerate(workers())
        @async responses[idx] = remotecall_fetch(foo, pid, args...)
    end
end

它会比这更快:

using Distributed

refs = Vector{Any}(undef, nworkers())
for (idx, pid) in enumerate(workers())
    refs[idx] = @spawnat pid foo(args...)
end
responses = [fetch(r) for r in refs]

第一个选项涉及每个工作流的网络访问周期,在第二个选项中,发生两个网络调用:第一个由宏执行 '@spawnat',第二个是一个方法 'fetch'(甚至 '等待')。 此外,方法 '获取'`wait'按顺序执行,这通常会降低性能。

消除有关退役的警告

过时的功能对相应警告的单个输出执行内部搜索。 这种额外的搜索操作可以显着减慢工作速度,因此所有使用过时功能的情况都应根据警告中的说明进行调整。

优化的微妙之处

在使用连续的内部循环时,应该考虑一些不太重要的点。

性能注释

有时您可以通过声明某些程序属性来改进优化。

  • 使用宏 `@inbounds'在表达式中禁用数组边界检查。 但只有当你确定结果时,才应该这样做。 如果超出可接受的索引限制,可能会发生数据故障或损坏。

  • 使用宏 '@fastmath'允许优化浮点数,这对于实数正确执行,但会导致IEEE数字的偏差。 请谨慎使用此功能,因为可以更改数值结果。 这个宏对应于clang中的`-ffast-math’参数。

  • 请注明 @simd在’for’循环之前,以表明迭代是相互独立的,它们的顺序可以改变。 请注意,Julia通常可以在没有'@simd`宏的情况下自动矢量化代码;只有在这种转换不可接受的情况下,它才有用,包括确保浮点数的关联性和忽略依赖的内存访问(`@simd ivdep')。 同样,在使用'@simd’语句时要非常小心,因为错误地使用依赖操作注释循环可能会导致意外的结果。 特别是,请记住,'setindex!`AbstractArray`的某些子类型的操作本质上取决于迭代的顺序。 此功能是实验性的,可能会在Julia的未来版本中更改或消失。

如果在数组中应用非标准索引,则索引AbstractArray数组的标准1:n习惯用法是不安全的,如果禁用边界检查,则可能导致分段错误。 使用’LinearIndices(x)`或’eachindex(x)'代替(另见章节 带有自定义索引的数组)。

虽然`@simd’必须在嵌套最深的`for`循环之前立即指定,但宏`@inbounds`和'@fastmath’可以应用于单个表达式或嵌套代码块中的所有表达式,例如使用'@inbounds begin’或'@inbounds for。..`.

下面是在同一时间使用`@inbounds`和`@simd`注释的示例(在这种情况下,使用'@noinline`,以便优化器不会破坏测试过程):

@noinline function inner(x, y)
    s = zero(eltype(x))
    for i=eachindex(x)
        @inbounds s += x[i]*y[i]
    end
    return s
end

@noinline函数innersimd(x,y)
    s=零(eltype(x))
    @simd for i=eachindex(x)
        @inbounds s+=x[i]*y[i]
    结束
    返回s
结束

功能时间(n,代表)
    x=兰德(Float32,n)
    y=兰德(Float32,n)
    s=零(Float64)
    时间=@经过j在1:代表
        s+=内(x,y)
    结束
    println("GFlop/sec=",2n*reps/time*1E-9)
    时间=@经过j在1:代表
        s+=innersimd(x,y)
    结束
    println("GFlop/sec(SIMD)=",2n*reps/time*1E-9)
结束

时间(1000,1000)

在具有2.4ghz Intel Core i5处理器的计算机上,结果如下:

GFlop/sec        = 1.9467069505224963
GFlop/sec (SIMD) = 17.578554163920018

(性能以’GFlop`sec’衡量,分数越高越好。)

下面是使用所有三个注释的示例。 该程序首先计算一维数组的最终差异,然后计算结果的L2范数。:

function init!(u::Vector)
    n = length(u)
    dx = 1.0 / (n-1)
    @fastmath @inbounds @simd for i in 1:n #утверждая, что `u` — это `Vector`, можно предположить, что индексирование начинается с 1
        u[i] = sin(2pi*dx*i)
    end
end

function deriv!(u::Vector, du)
    n = length(u)
    dx = 1.0 / (n-1)
    @fastmath @inbounds du[1] = (u[2] - u[1]) / dx
    @fastmath @inbounds @simd for i in 2:n-1
        du[i] = (u[i+1] - u[i-1]) / (2*dx)
    end
    @fastmath @inbounds du[n] = (u[n] - u[n-1]) / dx
end

function mynorm(u::Vector)
    n = length(u)
    T = eltype(u)
    s = zero(T)
    @fastmath @inbounds @simd for i in 1:n
        s += u[i]^2
    end
    @fastmath @inbounds return sqrt(s)
end

function main()
    n = 2000
    u = Vector{Float64}(undef, n)
    init!(u)
    du = similar(u)

    deriv!(u, du)
    nu = mynorm(du)

    @time for i in 1:10^6
        deriv!(u, du)
        nu = mynorm(du)
    end

    println(nu)
end

main()

在具有运行在2.7ghz的英特尔酷睿i7处理器的计算机上,结果将如下所示:

$ julia wave.jl;
  1.207814709 seconds
4.443986180758249

$ julia --math-mode=ieee wave.jl;
  4.487083643 seconds
4.443986180758249

这里,`--math-mode=ieee’参数禁用'@fastmath’宏,以便比较结果。

在这种情况下,使用'@fastmath’将速度提高约3.7倍。 这样的增加是不寻常的-作为一项规则,它发生得更少。 (在这个特定的例子中,测试数据的工作集相当小,完全适合处理器的L1缓存,因此访问内存的延迟无关紧要,计算所花费的时间主要由CPU负载决定。 在大多数现实世界的节目中,情况是不同的。)此外,在这种情况下,这种优化不会影响结果,但通常结果略有不同。 在某些情况下,特别是在使用数值不稳定的算法时,结果可能会有很大差异。

@Fastmath’注释重新排列浮点表达式,例如,通过更改计算顺序或假设某些特殊情况(inf,nan)是不可能的。 在这种情况下(在这个特定的计算机上),主要的区别在于`派生`函数中的表达式'1/(2*dx)`是从循环输出的(在它之外计算),就好像我们写了`idx=1/(2*dx)'。 在循环内部,表达式'+。.. /(2*dx)+`转换为+。.. *idx+`,其计算速度快得多。 当然,编译器应用的实际优化和由此产生的速度增益在很大程度上取决于硬件。 您可以使用Julia函数来了解生成的代码是如何变化的。 'code_native'

请注意,宏'@fastmath’还假设在计算过程中没有’NaN’值,这可能导致意外行为。:

julia> f(x) = isnan(x);

julia> f(NaN)
true

julia> f_fast(x) = @fastmath isnan(x);

julia> f_fast(NaN)
false

将非正规数视为零

非正规数字,以前称为https://en.wikipedia.org/wiki/Denormal_number [非规范化],在许多情况下都很有用,但在某些硬件上,它们会导致性能降低。 挑战 'set_zero_subnormals(true)'允许浮点运算将非正规输入或输出值解释为零,这可以提高某些硬件上的性能。 挑战 'set_zero_subnormals(false)'严格规定根据IEEE标准处理非正规值。

下面是一个非正规数字对某些硬件性能的显着影响的示例。

function timestep(b::Vector{T}, a::Vector{T}, Δt::T) where T
    @assert length(a)==length(b)
    n = length(b)
    b[1] = 1                            # Предельное условие
    for i=2:n-1
        b[i] = a[i] + (a[i-1] - T(2)*a[i] + a[i+1]) * Δt
    end
    b[n] = 0                            # Предельное условие
end

function heatflow(a::Vector{T}, nstep::Integer) where T
    b = similar(a)
    for t=1:div(nstep,2)                # Предполагаем, что nstep — четное число
        timestep(b,a,T(0.1))
        timestep(a,b,T(0.1))
    end
end

heatflow(zeros(Float32,10),2)#强制编译
试用=1:6
    a=零(Float32,1000)
    set_zero_subnormals(iseven(trial))#奇数尝试严格按照IEEE计算
    @时间热流(a,1000)
结束

结果会是这样的:

  0.002202 seconds (1 allocation: 4.063 KiB)
  0.001502 seconds (1 allocation: 4.063 KiB)
  0.002139 seconds (1 allocation: 4.063 KiB)
  0.001454 seconds (1 allocation: 4.063 KiB)
  0.002115 seconds (1 allocation: 4.063 KiB)
  0.001455 seconds (1 allocation: 4.063 KiB)

请注意,每个偶数迭代的运行速度明显更快。

在这个例子中,生成了许多非正规数,因为`a`中的值形成了一条指数递减的曲线,逐渐变平。

将非正规数字视为零应谨慎使用,因为它可能会扰乱某些表达式的操作,例如’x-y==0’意味着’x==y`:

julia> x = 3f-38; y = 2f-38;

julia> set_zero_subnormals(true); (x - y, x == y)
(0.0f0, false)

julia> set_zero_subnormals(false); (x - y, x == y)
(1.0000001f-38, false)

在某些应用中,将非正规数归零的替代方法是引入一点噪声。 例如,不是用零初始化对象`a`,而是按如下方式初始化它:

a = rand(Float32,1000) * 1.f-9

'@code_warntype'

'@code_warntype'(或其作为函数的变体 'code_warntype')有时可用于诊断类型问题。 下面是一个例子:

julia> @noinline pos(x) = x < 0 ? 0 : x;

julia> function f(x)
           y = pos(x)
           return sin(y*x + 1)
       end;

julia>@code_warntype f(3.2)
F(::Float64)的方法安装
  来自f(x)@主REPL[9]:1
争论
  #self#::核心。Const(f)
  x::浮64
当地人
  y::联合{Float64, Int64}
主体::Float64
1─(y=Main.pos(x))
│%2=(y*x)::Float64
│ %3 = (%2 + 1)::漂浮64
│%4=主要.罪(%3)::浮64
──回报%4

解释宏输出 '@code_warntype',以及相关宏 '@code_lowered', '@code_typed', '@code_llvm''@code_native'需要一些经验。 您的代码以一种在生成已编译机器代码期间经过重大重新设计的形式呈现。 大多数表达式都有'::T’形式的类型注释(其中’T’可以是,例如, 'Float64')。 宏最重要的特征 '@code_warntype'是非特定类型以红色显示。 由于本文档以Markdown格式编写,这并不意味着颜色突出显示,因此其中的红色文本以大写字母表示。

第一行显示函数的输出返回类型--'Body::Float64`。 以下几行表示SSA Julia中间表示格式中函数’f’的主体。 编号字段是表示代码中转换目标(通过`goto`)的标签。 如果你看一下函数体,你可以看到’pos’首先被调用,返回类型’Union’被输出。{Float64, Int64}`. 它以大写字母显示,因为它不是特定的。 这意味着不可能根据输入类型准确确定返回的类型`pos'。 但是,无论`y`是`Float64`还是`Int64`类型,操作`y*x`的结果都是`Float64’类型。 因此,结果是’f(x::Float64)`不会在类型上不稳定,即使一些中间计算是这样的。

如何使用这些信息取决于您。 显然,最好修复"pos",以确保类型稳定性。 在这种情况下,f`中的所有变量都是特定的,性能将是最佳的。 然而,在某些情况下,类型的这种时间不稳定并不重要。 例如,如果`pos’从不单独使用,则输出类型为`f`的事实是稳定的(对于类型的输入值 'Float64'),将保护进一步的代码免受类型不稳定的影响。 这在难以或不可能消除类型不稳定性的情况下尤其重要。 在这种情况下,上述提示(例如,关于添加类型注释或分离函数)是包含类型不稳定影响的最佳方法。 还要记住,即使是基本的Julia模块也具有类型不稳定的功能。 例如,函数 `findfirst返回数组中找到键的位置的索引,如果找不到键,则返回"nothing"-类型不稳定的明显情况。 为了更容易识别可能相关的类型不稳定情况,包含"缺失`或"无"的"联合"工会以黄色而不是红色突出显示。

下面的示例可以帮助您弄清楚如何解释标记为包含非有限类型的表达式。:

  • 函数的主体,以`Body::Union开始{T1,T2})` 解释:返回类型不稳定的函数。 建议:使返回类型稳定,即使它需要为此进行注释。

  • '调用主。g(%%x::Int64)::Union{Float64, Int64}` 解释:对类型不稳定函数`g’的调用。 建议:修复函数或在必要时注释返回值。

  • '调用基地。getindex(%%x::数组{Any,1},1::Int64)::任何` 解释:访问类型不佳数组的元素。 建议:使用具有更明确定义类型的数组,或者在必要时指定单个元素调用的类型。

  • "基地。getfield(%%x,:(:data))::数组{Float64,N} 哪里N` 解释:获取非有限类型的字段。 在这种情况下,类型’x',比方说’ArrayContainer',具有字段'data::Array{T}. 但是,为了使类型’Array’特定,维度’N’也是必需的。 建议:使用特定类型,例如’Array{T,3}'或’数组{T,N},其中’N’是’ArrayContainer’的参数。

捕获变量的性能

考虑下面的示例,它定义了一个内部函数。

function abmult(r::Int)
    if r < 0
        r = -r
    end
    f = x -> x * r
    return f
end

'Abmult’函数返回’f’函数,它将其参数乘以`r’的绝对值。 分配给`f’的内部函数称为闭包。 内部函数也用于’do’块和生成器表达式的语言。

这种编写代码的风格带来了性能挑战。 当将上述代码转换为较低级别的指令时,分析器通过提取内部函数并将其放置在单独的代码块中来重新排列它。 内部函数和外部作用域之间共享的"捕获"变量(如`r')也被提取到堆上的专用"容器"中,内部和外部函数都可以访问。 原因是,根据语言规范,即使在外部作用域(或在另一个内部函数中)更改了`r`,内部作用域中的变量`r`也必须与外部作用域中的`r`相同。

分析仪在前一段中提到。 这是指第一次加载包含`abmult`的模块的编译阶段,而不是第一次调用它的后期阶段。 分析器不知道’Int’是固定类型,或者运算符’r=-r`将’Int’的值转换为另一个值’Int'。 类型推断的魔力发生在编译的后期阶段。

因此,分析器不知道’r’具有固定类型(Int),或者`R’的值在创建内部函数后不会改变(因此,不需要容器)。 因此,分析器为包含抽象类型对象的容器创建代码,例如`Any`,这需要在运行时为每个`r`实例进行类型调度。 您可以通过将宏`@code_warntype`应用于上述函数来检查这一点。 运行时打包和调度类型都会降低性能。

如果捕获的变量用于性能非常重要的代码的一部分,那么您可以使用下面的提示。 首先,如果已知捕获的变量的类型没有变化,则可以使用类型注释显式声明它(应用于变量本身,而不是在正确的部分):

function abmult2(r0::Int)
    r::Int = r0
    if r < 0
        r = -r
    end
    f = x -> x * r
    return f
end

类型注释部分补偿了由于捕获而导致的性能损失,因为分析器可以将特定类型与容器中的对象相关联。 此外,如果捕获的变量根本不需要打包(因为它在创建闭包后不会重新打包),则可以使用`let`块指示如下。

function abmult3(r::Int)
    if r < 0
        r = -r
    end
    f = let r = r
            x -> x * r
    end
    return f
end

'Let’块创建一个新变量’r',其范围受内部函数限制。 第二种技术允许您在存在捕获变量的情况下完全恢复性能。 请记住:编译器的这一方面正在快速发展,并且在未来的版本中可能不需要对类型进行如此详细的注释以确保性能。 与此同时,可以使用来自社区成员的软件包自动添加`let`语句,如`abmult3`https://github.com/c42f/FastClosures.jl [快速曝光]。

多线程和线性代数

本节中的信息涉及Julia的多线程代码,该代码在每个线程中执行线性代数操作。 实际上,这些线性代数操作涉及使用BLAS/LAPACK调用,这些调用本身是多线程的。 在这种情况下,有必要确保内核不会因两种不同类型的多线程而过载。

Julia编译并使用自己的OpenBLAS副本进行线性代数,线程数由环境变量’OPENBLAS_NUM_THREADS’控制。 它可以在启动Julia时设置为命令行参数,也可以在Julia会话期间使用`BLAS更改。set_num_threads`N)`(`BLAS’子模块使用’using LinearAlgebra’导出)。 其当前值可通过’BLAS访问。get_num_threads()'。

如果用户没有指定任何内容,Julia会尝试为OpenBLAS流的数量选择一个合理的值(例如,基于平台,Julia版本等。). 但是,通常建议手动检查和设置值。 OpenBLAS的操作如下。

  • 如果’OPENBLAS_NUM_THREADS=1',OpenBLAS使用调用Julia线程(或线程),即它在Julia线程中执行计算。

  • 如果’OPENBLAS_NUM_THREADS=N>1`,OpenBLAS创建并管理自己的线程池(总数为’N`)。 所有Julia线程只使用一个OpenBLAS线程池。

当以`JULIA_NUM_THREADS=X’在多线程模式下运行Julia时,通常建议设置’OPENBLAS_NUM_THREADS=1'。 鉴于上面描述的行为,将BLAS线程数增加到’N>1’很容易导致性能下降,特别是当`N<<X`时。 但是,这只是一个经验法则,设置每个线程数的最佳方法是尝试特定的现有应用程序。

替代线性代数后端

作为OpenBLAS的替代方案,还有其他几个后端可以帮助线性代数性能。 值得注意的例子是https://github.com/JuliaLinearAlgebra/MKL.jl [MKL.jl]和https://github.com/JuliaMath/AppleAccelerate.jl [AppleAccelerate.jl]。

这些都是外部包,所以我们不会在这里详细讨论它们。 请参阅相关文档(特别是因为它们关于多线程的行为与OpenBLAS的行为不同)。

执行延迟、下载时间和包的预编译

减少直到显示第一个图形的时间等。

第一次调用Julia方法时,它(以及它调用的所有方法,或者那些可以静态定义的方法)将被编译。 这可以通过一系列宏来证明 '@time'

julia> foo() = rand(2,2) * rand(2,2)
foo (generic function with 1 method)

julia> @time @eval foo();
  0.252395 seconds (1.12 M allocations: 56.178 MiB, 2.93% gc time, 98.12% compilation time)

julia> @time @eval foo();
  0.000156 seconds (63 allocations: 2.453 KiB)

请注意,'@time@eval’更适合测量编译时间,因为没有 `@eval'部分编译可能在倒计时之前已经完成。

在开发包时,您可以通过使用正向编译来改善未来的用户体验,以便他们使用的代码已经编译。 为了有效地预编译包代码,建议使用https://julialang …​github.io/PrecompileTools.jl/stable /[`预编译工具。jl']过程中运行"预编译工作负载"。 此工作负载表示包的标准用法,并将其自己的编译代码缓存到’pkgimage’包缓存中,从而显着缩短"首次执行时间"(通常称为TTFX)。

如果您不想在预编译上花费额外的时间(这在包开发期间可能需要),工作负载https://julialang …​github.io/PrecompileTools.jl/stable /[`预编译工具。jl']可以禁用,有时可以通过设置首选值进行调整。

减少包装装载时间

通常建议减少软件包下载时间。 开发人员可以使用以下提示。

  1. 减少依赖的数量,只留下那些你真正需要的。 考虑使用包扩展来支持与其他包的交互,而不会过度增加核心依赖项的数量。

  2. 尽量不要使用函数 '__init__()`, 如果没有其他选择。 对于那些可能导致多个编译运行或只是需要很长时间才能完成的情况尤其如此。

  3. 如果可能,更正https://julialang.org/blog/2020/08/invalidations /[invalidations]在依赖项和包代码中。

要检查上述条件,您可以使用该工具 `@time_imports'在REPL中。

julia> @time @time_imports using Plots
      0.5 ms  Printf
     16.4 ms  Dates
      0.7 ms  Statistics
               ┌ 23.8 ms SuiteSparse_jll.__init__() 86.11% compilation time (100% recompilation)
     90.1 ms  SuiteSparse_jll 91.57% compilation time (82% recompilation)
      0.9 ms  Serialization
               ┌ 39.8 ms SparseArrays.CHOLMOD.__init__() 99.47% compilation time (100% recompilation)
    166.9 ms  SparseArrays 23.74% compilation time (100% recompilation)
      0.4 ms  Statistics → SparseArraysExt
      0.5 ms  TOML
      8.0 ms  Preferences
      0.3 ms  PrecompileTools
      0.2 ms  Reexport
... many deps omitted for example ...
      1.4 ms  Tar
               ┌ 73.8 ms p7zip_jll.__init__() 99.93% compilation time (100% recompilation)
     79.4 ms  p7zip_jll 92.91% compilation time (100% recompilation)
               ┌ 27.7 ms GR.GRPreferences.__init__() 99.77% compilation time (100% recompilation)
     43.0 ms  GR 64.26% compilation time (100% recompilation)
               ┌ 2.1 ms Plots.__init__() 91.80% compilation time (100% recompilation)
    300.9 ms  Plots 0.65% compilation time (100% recompilation)
  1.795602 seconds (3.33 M allocations: 190.153 MiB, 7.91% gc time, 39.45% compilation time: 97% of which was recompilation)

请注意,在此示例中加载了多个包。 其中一些包含函数`__init__()`,其中一些导致编译,另一些导致重新编译。 重新编译是由以前的包使方法无效这一事实引起的。 因此,在以下包运行其函数'__init__()'的情况下,重新编译发生在代码执行之前。

另外,请注意,'Statistics`扩展’SparseArraysExt’已被激活,因为’SparseArrays’位于依赖树中,即参见`0.4ms Statistics→SparseArraysExt'。

此报告允许您分析加载依赖项的成本是否值得它们带来的功能。 使用实用程序’Pkg''why',您可以找出存在间接依赖的原因。

(CustomPackage) pkg> why FFMPEG_jll
  Plots → FFMPEG → FFMPEG_jll
  Plots → GR → GR_jll → FFMPEG_jll

或者要查看包引入的间接依赖关系,您可以为包运行`pkg>rm`,查看从清单中删除的依赖关系,然后使用`pkg>undo`恢复更改。

如果加载时间受慢方法`__init__()的影响,那么定义编译组件的高级方法之一是使用参数--trace-compile=stderr’Julia,它将报告表达式 `precompile'每次方法编译时。 例如,完整的设置将如下所示。

$julia--startup-file=no--trace-compile=stderr
julia>@time@time_imports使用CustomPackage
...

请注意表达式'--startup-file=no',它有助于将测试与启动中可能存在的包隔离开来。jl.

可以使用包对重新编译的原因进行更详细的分析https://github.com/timholy/SnoopCompile.jl ['SnoopCompile']。