Julia 学习笔记——性能优化与并行计算
Julia 最大的吸引力之一,就是“高层写法”和“高性能”之间的距离没有传统动态语言那么远。
但这并不意味着随便写都能快。Julia 的性能优化有一套非常典型的思路,而并行计算又是另一个常被误解的话题,所以这里把它们放到一篇里做一个够用版整理。
概述
先记下面几点:
- Julia 可以快,但前提是写法利于类型推断和特化编译;
- 第一次运行慢,往往是编译开销,不一定是算法本身慢;
- 真正的性能问题,很多时候不是“语法不够底层”,而是分配太多、类型不稳定、全局变量太多;
- 并行计算不是给代码加个宏就自动变快,任务划分、内存分配和数据传递同样重要。
性能分析基础
@time
最基本的计时方式:
1 | sum(rand(10^6)) |
它一般会显示:
- 执行时间;
- 内存分配次数与大小;
- GC 时间比例等。
但是要特别注意:第一次运行时通常包含编译成本,所以参考意义有限。
更稳妥的做法是至少跑两次:
1 | f(x) # first run: compile + execute |
BenchmarkTools
真正做基准测试,还是用 BenchmarkTools.jl 更合适:
1 | using BenchmarkTools |
这里的 $x 很重要,它表示把外部变量插值进 benchmark 环境,避免全局变量和闭包环境污染测试结果。
最常见的性能问题
全局变量
这是 Julia 初学者最容易碰到的性能坑之一。
例如:
1 | x = rand(10^6) |
这里 x 是全局变量,编译器往往不能像处理局部已知类型那样放心优化。
更合适的写法:
1 | function f(x) |
或者把真正不变的全局量写成 const。
类型不稳定
如果函数的返回类型不能稳定推断,性能往往会明显变差。
例如:
1 | function g(x) |
这个函数可能返回 Int64,也可能返回 Float64,编译器处理起来就更困难。
更理想的写法是让返回类型稳定。
抽象类型容器
例如:
1 | a = Any[1, 2.0, "3"] |
这种容器很灵活,但对高性能数值代码通常不是好消息。
更合适的是尽量让容器元素类型具体、统一:
1 | b = Float64[1, 2, 3] |
不必要的内存分配
在 Julia 中,很多性能问题最终都能追到“分配太多临时对象”。
常见来源包括:
- 不必要的切片拷贝;
- 广播链条写得不好;
- 循环里不断创建临时数组;
- 字符串频繁拼接;
- 本可以原地更新却写成了返回新对象。
常见优化手段
写函数,不要堆在脚本顶层
Julia 的性能优化几乎总是从“把逻辑放进函数”开始。
这是最朴素、但往往最有效的一条原则。
原地更新
如果操作允许,优先考虑原地更新:
1 | A .= sin.(A) |
这里:
.=会减少临时数组;- 带
!的函数通常表示会修改输入; - 这往往能显著减少分配。
使用 view / @views
如果只是想引用数组的一部分,而不是拿一个新拷贝:
1 | v = view(A, :, 1) |
或者:
1 | begin |
遍历时优先 eachindex
1 | for i in eachindex(a) |
这通常比直接写 1:length(a) 更稳妥,也更适合泛型数组。
@inbounds
跳过边界检查:
1 | for i in eachindex(a) |
只有在你能确保索引绝对安全时才应该用。
@simd
提示编译器尝试 SIMD:
1 | for i in eachindex(a) |
但它不是魔法开关,收益取决于循环结构和数据布局。
看懂编译器反馈
@code_warntype
这是定位类型不稳定的核心工具之一:
1 | 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 | Threads. for i in eachindex(a) |
这类写法适合:
- 每次迭代彼此独立;
- 计算量足够大;
- 不存在写冲突。
线程安全
多线程里最需要小心的是共享可变状态。
例如下面这种累加就有竞争风险:
1 | s = 0.0 |
更合理的方式通常是:
- 每个线程局部累加;
- 最后再归约;
- 或者使用专门线程安全结构。
任务与异步
Julia 提供轻量级任务(task)和通道(channel)。
@async
1 | begin |
这更接近协作式并发,而不等同于 CPU 并行。
Channel
1 | ch = Channel{Int}(2) |
它很适合生产者-消费者这类模式。
分布式计算
标准库 Distributed 支持多进程分布式计算:
1 | using Distributed |
常见接口包括:
@everywherepmapremotecall
例如:
1 | pmap(x -> x^2, 1:10) |
这类方式更适合任务粒度较大、彼此独立的情况。
并行不一定更快
一定要强调这件事。
下面这些情况,加并行经常没收益甚至更慢:
- 任务太小;
- 分配太多;
- 线程之间争用严重;
- 数据传输成本太高;
- 本来就受内存带宽限制。
因此并行优化的正确顺序通常是:
- 先把单线程写对、写稳
- 先减少分配、稳定类型
- 再决定是否值得并行
小结
Julia 的性能优化没有那么玄,最常见的问题还是全局变量、类型不稳定和分配太多。并行计算也是一样,不是给代码加个宏就自动变快,单线程代码先写稳,再去看线程、任务和分布式,顺序不要反。
