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 宏测量执行时间,重点关注内存分配。第一次调用包含编译时间,要看第二次的结果:
实例
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 高性能的第一原则:
实例
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)
变量在循环中改变类型同样有害:
实例
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 可以显示编译器推断的类型,红色(非具体类型)的部分就是问题所在:
实例
MethodInstance for pos(::Float64)
Arguments
x::Float64
Locals
y::Union{Float64, Int64} # Union 说明类型不稳定!
Body::Float64
避免抽象类型的容器
元素类型是抽象类型的数组性能很差,因为每个元素都是指向堆对象的指针:
实例
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)存储,即第一列存完再存第二列。内层循环遍历第一维最快:
实例
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, :] 默认会复制数据。如果只是临时用一下切片,用 @views 或 view() 创建引用原数组的视图更高效:
实例
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)
优先使用点语法融合
嵌套的"点"运算会融合成单次循环,不产生临时数组:
实例
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 包,编译器会为具体尺寸生成完全展开的代码:
实例
# 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 确认瓶颈所在,再针对性修改,避免凭直觉过早优化。
