Julia 最大的吸引力之一,就是“高层写法”和“高性能”之间的距离没有传统动态语言那么远。

但这并不意味着随便写都能快。Julia 的性能优化有一套非常典型的思路,而并行计算又是另一个常被误解的话题,所以这里把它们放到一篇里做一个够用版整理。

概述

先记下面几点:

  • Julia 可以快,但前提是写法利于类型推断和特化编译;
  • 第一次运行慢,往往是编译开销,不一定是算法本身慢;
  • 真正的性能问题,很多时候不是“语法不够底层”,而是分配太多、类型不稳定、全局变量太多;
  • 并行计算不是给代码加个宏就自动变快,任务划分、内存分配和数据传递同样重要。

性能分析基础

@time

最基本的计时方式:

1
@time sum(rand(10^6))

它一般会显示:

  • 执行时间;
  • 内存分配次数与大小;
  • GC 时间比例等。

但是要特别注意:第一次运行时通常包含编译成本,所以参考意义有限。

更稳妥的做法是至少跑两次:

1
2
@time f(x)  # first run: compile + execute
@time f(x) # second run: closer to steady-state

BenchmarkTools

真正做基准测试,还是用 BenchmarkTools.jl 更合适:

1
2
3
using BenchmarkTools

@btime sum($x)

这里的 $x 很重要,它表示把外部变量插值进 benchmark 环境,避免全局变量和闭包环境污染测试结果。

最常见的性能问题

全局变量

这是 Julia 初学者最容易碰到的性能坑之一。

例如:

1
2
3
4
5
6
7
8
9
x = rand(10^6)

function f()
s = 0.0
for i in x
s += i
end
s
end

这里 x 是全局变量,编译器往往不能像处理局部已知类型那样放心优化。

更合适的写法:

1
2
3
4
5
6
7
function f(x)
s = 0.0
for i in x
s += i
end
s
end

或者把真正不变的全局量写成 const

类型不稳定

如果函数的返回类型不能稳定推断,性能往往会明显变差。

例如:

1
2
3
4
5
6
7
function g(x)
if x > 0
return 1
else
return 1.0
end
end

这个函数可能返回 Int64,也可能返回 Float64,编译器处理起来就更困难。

更理想的写法是让返回类型稳定。

抽象类型容器

例如:

1
a = Any[1, 2.0, "3"]

这种容器很灵活,但对高性能数值代码通常不是好消息。

更合适的是尽量让容器元素类型具体、统一:

1
b = Float64[1, 2, 3]

不必要的内存分配

在 Julia 中,很多性能问题最终都能追到“分配太多临时对象”。

常见来源包括:

  • 不必要的切片拷贝;
  • 广播链条写得不好;
  • 循环里不断创建临时数组;
  • 字符串频繁拼接;
  • 本可以原地更新却写成了返回新对象。

常见优化手段

写函数,不要堆在脚本顶层

Julia 的性能优化几乎总是从“把逻辑放进函数”开始。

这是最朴素、但往往最有效的一条原则。

原地更新

如果操作允许,优先考虑原地更新:

1
2
3
A .= sin.(A)
sort!(x)
mul!(y, A, x)

这里:

  • .= 会减少临时数组;
  • ! 的函数通常表示会修改输入;
  • 这往往能显著减少分配。

使用 view / @views

如果只是想引用数组的一部分,而不是拿一个新拷贝:

1
v = view(A, :, 1)

或者:

1
2
3
@views begin
x = A[:, 1]
end

遍历时优先 eachindex

1
2
3
for i in eachindex(a)
s += a[i]
end

这通常比直接写 1:length(a) 更稳妥,也更适合泛型数组。

@inbounds

跳过边界检查:

1
2
3
@inbounds for i in eachindex(a)
s += a[i]
end

只有在你能确保索引绝对安全时才应该用。

@simd

提示编译器尝试 SIMD:

1
2
3
@simd for i in eachindex(a)
s += a[i]
end

但它不是魔法开关,收益取决于循环结构和数据布局。

看懂编译器反馈

@code_warntype

这是定位类型不稳定的核心工具之一:

1
@code_warntype g(1)

如果输出里有很多不稳定类型提示,通常说明这段代码还不够利于推断。

@code_typed / @code_llvm / @code_native

这些工具可以继续往下看:

  • Julia 推断后的类型化 IR;
  • LLVM IR;
  • 本地机器码。

日常写笔记和普通项目未必需要深入,但知道它们存在很重要。

广播、循环与向量化

在 Python 和 MATLAB 中,向量化通常兼具“好看”和“更快”两重意义。

在 Julia 中则不一定:

  • 直接写 for 循环通常已经足够快;
  • 广播写法同样常常很好;
  • 真正关键不是“看起来像不像向量化”,而是分配、类型稳定和访问模式是否合理。

例如:

1
y .= 2 .* x .+ sin.(x)

这类点语法通常能融合成一次广播循环,是很好的写法。

但如果写成多个中间数组叠加,就未必划算。

并行计算概述

Julia 的并行能力大致可以分成几类:

  • 多线程:多个线程共享同一进程内存
  • 任务 / 协程:适合异步调度与并发
  • 多进程 / 分布式:适合跨进程甚至跨机器

很多人第一次看到 Julia 并行支持很兴奋,但需要注意:并行并不是“默认更快”,因为调度、同步、内存竞争、通信都要付成本。

多线程

启动线程

常见方式:

1
julia -t auto

或者:

1
julia -t 8

查看线程数:

1
Threads.nthreads()

Threads.@threads

最常见的并行 for:

1
2
3
Threads.@threads for i in eachindex(a)
a[i] = 2a[i]
end

这类写法适合:

  • 每次迭代彼此独立;
  • 计算量足够大;
  • 不存在写冲突。

线程安全

多线程里最需要小心的是共享可变状态。

例如下面这种累加就有竞争风险:

1
2
3
4
s = 0.0
Threads.@threads for i in eachindex(a)
s += a[i] # race condition
end

更合理的方式通常是:

  • 每个线程局部累加;
  • 最后再归约;
  • 或者使用专门线程安全结构。

任务与异步

Julia 提供轻量级任务(task)和通道(channel)。

@async

1
2
3
@async begin
println("running...")
end

这更接近协作式并发,而不等同于 CPU 并行。

Channel

1
2
3
4
ch = Channel{Int}(2)

@async put!(ch, 1)
take!(ch) # 1

它很适合生产者-消费者这类模式。

分布式计算

标准库 Distributed 支持多进程分布式计算:

1
2
using Distributed
addprocs(4)

常见接口包括:

  • @everywhere
  • pmap
  • remotecall

例如:

1
pmap(x -> x^2, 1:10)

这类方式更适合任务粒度较大、彼此独立的情况。

并行不一定更快

一定要强调这件事。

下面这些情况,加并行经常没收益甚至更慢:

  • 任务太小;
  • 分配太多;
  • 线程之间争用严重;
  • 数据传输成本太高;
  • 本来就受内存带宽限制。

因此并行优化的正确顺序通常是:

  1. 先把单线程写对、写稳
  2. 先减少分配、稳定类型
  3. 再决定是否值得并行

小结

Julia 的性能优化没有那么玄,最常见的问题还是全局变量、类型不稳定和分配太多。并行计算也是一样,不是给代码加个宏就自动变快,单线程代码先写稳,再去看线程、任务和分布式,顺序不要反。