现在位置: 首页 > Julia 教程 > 正文

Julia 性能技巧

Julia 的性能来自类型推断和 JIT 编译:编译器需要知道每个值的类型才能生成高效代码。因此 Julia 的性能问题几乎都源于"编译器猜不出类型",掌握了这一点,写出接近 C 速度的代码并不难。


性能关键代码放进函数

任何性能关键的代码都应该放进函数里。函数内的代码会被编译优化,而顶层(全局作用域)代码运行慢得多:

实例

# 不好:写在脚本顶层的循环很慢
for i in 1:1_000_000
    x += sin(i)     # 慢
end

# 好:封装成函数
function mysum(n)
    x = 0.0
    for i in 1:n
        x += sin(i)     # 快
    end
    return x
end

避免非 const 的全局变量

全局变量的值(和类型)随时可能改变,编译器无法优化使用它的代码。解决方法:要么用参数传给函数,要么声明为 const:

实例

# 不好:函数直接使用全局变量
julia> x = rand(1000);

julia> function sum_global()
           s = 0.0
           for i in x          # x 是全局变量,慢
               s += i
           end
           return s
       end

# 第一次调用包含编译时间,看第二次
julia> @time sum_global()
  0.000091 seconds (3.49 k allocations: 70.156 KiB)      # 有大量额外分配

# 好:把 x 作为参数传入
julia> function sum_arg(x)
           s = 0.0
           for i in x
               s += i
           end
           return s
       end

julia> @time sum_arg(x)
  0.000006 seconds (1 allocation: 16 bytes)              # 零分配,快了 15 倍

如果全局变量确实不可少,加上 const 声明:

const DEFAULT_VAL = 0

测量性能:@time

@time 宏测量执行时间,重点关注内存分配。第一次调用包含编译时间,要看第二次的结果:

实例

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

# @btime(BenchmarkTools 包)更专业,自动多次采样
julia> using BenchmarkTools

julia> @btime my_function()
  1.083 μs (0 allocations: 0 bytes)

分配越少越好。纯粹的数值计算(向量求和等)应该做到 0 allocation。意外的大量分配通常意味着类型不稳定。

类型稳定性

类型稳定指函数的返回类型只由参数类型决定,与参数的值无关。这是 Julia 高性能的第一原则:

实例

# 不好:返回类型取决于 x 的值(可能返回 Int 也可能返回 Float64)
julia> pos(x) = x < 0 ? 0 : x
pos (generic function with 1 method)

# 好:zero(x) 保证返回值和 x 类型一致
julia> pos(x) = x < 0 ? zero(x) : x
pos (generic function with 1 method)

变量在循环中改变类型同样有害:

实例

# 不好:x 先是 Int,第一次循环后变成 Float64
julia> function foo()
           x = 1
           for i = 1:10
               x /= rand()
           end
           return x
       end

# 好:初始化为 Float64,类型从头到尾不变
julia> function foo()
           x = 1.0
           for i = 1:10
               x /= rand()
           end
           return x
       end

用 @code_warntype 检查类型

@code_warntype 可以显示编译器推断的类型,红色(非具体类型)的部分就是问题所在:

实例

julia> @code_warntype pos(-1.5)
MethodInstance for pos(::Float64)
Arguments
  x::Float64
Locals
  y::Union{Float64, Int64}      # Union 说明类型不稳定!
Body::Float64

避免抽象类型的容器

元素类型是抽象类型的数组性能很差,因为每个元素都是指向堆对象的指针:

实例

# 不好:元素类型是抽象类型 Real
julia> a = Real[1, 2.0, pi]
3-element Vector{Real}

# 好:元素都是 Float64,紧凑存储
julia> a = Float64[1, 2.0, pi]
3-element Vector{Float64}:
 1.0
 2.0
 3.141592653589793

同理,结构体字段尽量用具体类型,抽象类型字段应该换成类型参数:

实例

# 不好:字段是抽象类型,每次访问都要动态查类型
julia> struct MyStillAmbiguousType
           a::AbstractFloat
       end

# 好:用类型参数,每个实例的字段类型固定
julia> mutable struct MyType{T<:AbstractFloat}
           a::T
       end

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

特别地,把函数存进结构体字段时不要用抽象的 Function 类型:

实例

# 不好
struct BadWrapper
    f::Function             # Function 是抽象类型
end

# 好:F 是类型参数,调用零开销
struct GoodWrapper{F}
    f::F
end

预分配输出数组

循环中反复创建数组会产生大量分配和 GC 压力。把输出数组在循环外创建好,重复使用:

实例

# 不好:每次调用都分配新数组
julia> function xinc(x)
           return [x + i for i in 1:3000]
       end;

# 好:输出写入预分配的数组
julia> function xinc!(ret, x)
           for i in 1:3000
               ret[i] = x + i
           end
           nothing
       end;

# 计时对比:预分配版本快 30 倍
julia> @time loopinc()
  0.297454 seconds (200.00 k allocations: 2.239 GiB, 39.80% gc time)

julia> @time loopinc_prealloc()
  0.009410 seconds (2 allocations: 23.477 KiB)

数组访问顺序:按列优先

Julia 数组按列优先(column-major)存储,即第一列存完再存第二列。内层循环遍历第一维最快:

列主序内存布局示意图

实例

# 好:i 是内层循环,按内存顺序访问(列优先)
julia> function copy_col_row(x)
           out = similar(x)
           for col in axes(x, 2), row in axes(x, 1)
               out[row, col] = x[row, col]
           end
           return out
       end

# 不好:j 是内层循环,跳跃访问内存,慢很多
julia> function copy_row_col(x)
           out = similar(x)
           for row in axes(x, 1), col in axes(x, 2)
               out[row, col] = x[row, col]
           end
           return out
       end

用视图避免复制

数组切片 A[1:5, :] 默认会复制数据。如果只是临时用一下切片,用 @viewsview() 创建引用原数组的视图更高效:

实例

# 复制版本:分配 7.6MB
julia> fcopy(x) = sum(x[2:end-1]);

# 视图版本:零复制,快 3 倍
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)

优先使用点语法融合

嵌套的"点"运算会融合成单次循环,不产生临时数组:

实例

# 不好:3*A、A.^2、4*A 各产生一个临时数组
julia> f(x) = 3x.^2 + 4x + 7x.^3;

# 好:@. 宏给所有运算加点,全部融合成一次循环
julia> fdot(x) = @. 3x^2 + 4x + 7x^3;

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)

本例中 fdot 快了 10 倍、内存只有 1/6。

小数组的加速:StaticArrays

长度固定且很小(小于 100 个元素)的数组,可以用 StaticArrays.jl 包,编译器会为具体尺寸生成完全展开的代码:

实例

julia> using StaticArrays

# SVector:固定长度的静态向量,栈上分配
julia> v = @SVector [1.0, 2.0]
2-element SVector{2, Float64} with indices SOneTo(2):
 1.0
 2.0

julia> norm(v)     # 完全展开成几条机器指令,零分配
2.23606797749979

编译延迟与预编译

Julia 的第一次调用包含编译时间(TTFX 问题)。缓解方法:

方法说明
PrecompileTools.jl包作者用它记录典型用法,让用户加载时自动预编译
PackageCompiler.jl把包和依赖编译成自定义系统镜像
减少依赖依赖越少加载越快,用 @time using 包名 排查慢依赖

性能优化清单

检查项要点
代码在函数里吗?性能关键代码必须封装为函数
有全局变量吗?改成参数传递,或加 const
类型稳定吗?用 @code_warntype 检查,消灭 Union
容器类型具体吗?避免 Vector{Any}、AbstractFloat 字段
分配多吗?数值计算应零分配,预分配输出
循环顺序对吗?列优先:内层循环走第一维
该用点语法吗?嵌套数组运算用 . 融合
数组小而固定吗?考虑 StaticArrays.jl
切片要复制吗?临时切片用 @views

提醒:先测量再优化。用 @time 或 BenchmarkTools 确认瓶颈所在,再针对性修改,避免凭直觉过早优化。