先说结论:Julia 程序那一分钟,绝大部分不是在算,而是在“开机”。 慢的是冷启动,不是算法。把账拆清楚之后,治法其实很分层:日常用常驻进程就能绕开,正式发布再考虑打包。

一、问题:每跑一个算例,先等一分钟

我用 Julia 重写了一个地震定位程序(绝对定位 + 双差重定位),和 Fortran 写的原版程序(HYPOINVERSE 与 HypoDD)在同一台老服务器上做过同机对拍:定位精度不相上下,有些场景还更稳。但日常使用有个很烦的差别——每次在命令行跑一个算例:

1
julia --project=. bin/maoyeloc.jl case.toml

都要等 约 59–75 秒 才出结果。换成原版 Fortran 程序,同样一个算例只要 0.02–0.04 秒。三个数量级的差距,问题出在哪?

二、拆账:59 秒里,真求解只占一小块

Julia 是即时编译(JIT)的:函数第一次被调用时,才会针对实际的参数类型做类型推断并编译成机器码。这带来一个著名的“首次运行延迟”(社区叫 TTFX,time to first X):代码第一次跑的那一遍,要替后面所有运行把编译的账先付了。

拿一个常规算例(绝对定位,20 个事件)把墙钟拆开看:

阶段 耗时
墙钟总计 59.4 s
其中:进程启动 + 包加载 + 本次调用路径的即时编译 约 38 s
进程内执行合计 21.1 s
—— 求解本身 11.4 s
—— 不确定度估计 3.7 s
—— 结果导出 2.8 s
—— 分析与其余准备 3.2 s

最能说明问题的是另一个数字:在同一个 Julia 进程里紧接着跑第二个相同算例,只需 0.12 秒。 编译账付过一次之后,求解的稳态成本就现形了。换到双差重定位也一样:逐例起命令行约 64–74 秒,同一进程内稳态 0.22 秒一例。

还有个旁证:把线程数从 1 加到 4,墙钟 59.1 秒 vs 59.4 秒,几乎没变——瓶颈压根不在求解的并行度上。

所以定性很清楚:逐例起命令行的慢,是冷启动型慢,不是算法型慢。 稳态下每例 0.1–0.2 秒,和 Fortran 同数量级(约慢 6 倍),实际工作中完全可以忽略。

三、三个治法,按成本从低到高

1. 常驻进程:零成本,先用这个

既然贵的是“开机”,那就别反复开机。开发和批量处理时,把 Julia 进程开着,用 API 在进程内循环跑算例:

1
2
3
4
5
using MaoyeLoc

for f in readdir("cases"; join=true)
endswith(f, ".toml") && run_case(f)
end

第一个算例替后面所有算例付编译账,从第二个起就是稳态速度。REPL、VS Code 的 Julia 插件、Jupyter 内核都是这个路子。对“一天要跑几百个算例”的工作流,这一条就够了,什么都不用改。

2. 预编译:把常用路径提前编好

Julia 的包在安装后会做预编译,把一部分编译结果存进缓存(1.9 起默认启用 pkgimages,包加载已经比早年快很多)。但缓存覆盖的主要是包自身的代码,你自己的调用组合第一次走到的路径,仍然要现编。

补法是给包写预编译工作负载,用 PrecompileTools 把典型算例在预编译阶段先跑一遍:

1
2
3
4
5
6
7
8
using PrecompileTools

@setup_workload begin
@compile_workload begin
# 在这里跑一个最小算例,
# 让常用代码路径提前编译进缓存
end
end

效果是把一部分 TTFX 从“用户第一次运行”挪到“安装时付清”。局限也明确:没被工作负载覆盖到的路径,照样现编现等。

3. sysimage 与 PackageCompiler:冻结成可执行程序

最彻底的一层,是把 Julia 运行时、包和已编译代码一起冻结成一个系统镜像(sysimage),或用 PackageCompiler 直接打成一个可执行程序:

1
2
3
using PackageCompiler

create_app("path/to/MaoyeLoc", "maoyeloc-app")

产物就能像普通命令行程序那样用:

1
./maoyeloc-app/bin/maoyeloc case.toml

启动延迟基本消失,代价同样实在:构建一次要几分钟到几十
…[truncated 615 chars]