交付形态¶
一份 Fortran 源码,四种交付形态。桥调用在它们之间不变;变的是这些调用背后的 Julia 代码如何到达机器——从项目环境解释执行、预编译进嵌入式运行时加载的 sysimage、或是提前编译成一个普通的共享库——直到队尾的完全不用 Julia。第四种 形态,纯 Fortran 的 native,是前三种共同的度量基线。
谱系¶
以下数字量自 capstone 反应流示例(完整报告:
examples/07_capstone/PERFORMANCE.md):
| 形态 | 启动 | 目标机需要什么 | 目标机上有 Julia 吗? |
|---|---|---|---|
| JIT(嵌入式,项目环境) | init 17.3 s | 你的二进制、钉版本的 Julia 安装、项目环境及其包仓库 | 有 |
| sysimage | init 1.90 s | 你的二进制加 sysimage 文件,由同一个 Julia 构建 | 有 |
| AOT 库 | 库 init 0.58–1.97 s | 你的二进制加编译出的 .so |
没有 |
| native(纯 Fortran) | 无 | 你的二进制 | 没有 |
连成阶梯:JIT 冷启动到首个结果 17.3 s;sysimage 降到 1.90 s;AOT 版本 首次调用约 12 μs,逼近进程里根本没有 Julia 的 native 基线。(12 μs 是进 编译库的一次平凡调用;第一次真正的化学调用在任何形态下都是毫秒级的求解器 工作——定这个数的是工作量,不是形态。)
同一台机器上多次运行的启动区间:JIT 15.1–20.2 s(要加载约 92 个包缓存;高 端发生在机器繁忙时),sysimage 0.93–2.37 s(低端是热页缓存),AOT 0.58–1.97 s(库的冷到热页缓存)。
什么变,什么不变¶
启动变;每次调用的开销不变。 热态调用在每种形态下走的都是同一套 fcj_*
ABI,因此两套 SDK,两种速度里的数字到处成立:泛型约 815 ns,
Fast Path 约 3.8 ns。capstone 的化学总时长给出实证——同样的 5216 个 Strang
半步上 4.79 s(JIT)对 4.02 s(sysimage),而差值的方向在不同运行间翻转。
那是机器负载噪声(约 ±20%),不是形态效应。为启动延迟、为交付物选形态;别为
稳态速度选。
内存同样不变:sysimage 运行的峰值 RSS 与 JIT 运行相差不超过 ~30 MB。镜像 预编译的是代码,压缩不了你的问题本身需要的堆。
形态 1:JIT——开发者的默认¶
用 julia_init() 或 julia_init_ex(project_path=...) 初始化,让 Julia 在运行
时从仓库加载预编译的包缓存。没有额外的构建步骤,每个包都经自己的 [compat]
边界 resolve,一条顶层的 julia_eval_string 就能现场重定义代码。代价是
17.3 s 量级的启动,以及一台背得动整个包仓库的目标机。
省略 project_path 时,julia_init_ex 按序探测 FortranBridge 包:环境变量
$JULIA_FJ_PROJECT,然后装在可执行文件旁边的副本,然后 $JULIA_PROJECT。
探测失败则不初始化直接返回,原因在 get_last_error_message() 里。
形态 2:sysimage——构建时付账,两秒钟加载¶
cmake -DFJ_BUILD_SYSIMAGE=ON 驱动 scripts/build_sysimage.jl:PackageCompiler
的 create_sysimage 编译 FortranBridge、SciMLBase、OrdinaryDiffEq 与
FjKinetics,把一份预编译工作负载追进镜像。产物是一个大文件——本仓库载满
SciML 的镜像约一个 GB——由嵌入式运行时在 init 时加载。运行时不编译任何东西,
这是构造使然:代码已经在镜像里。
capstone 驱动用一个分支在前两种形态间切换,摘自
examples/07_capstone/1_reacting_flow.f90:
if (trim(jmode) == 'sysimage') then
inquire(file=trim(image_path), exist=lex)
if (.not. lex) then
write (error_unit, '(a)') 'FATAL: sysimage not found at ' // &
trim(image_path) // ' (set FJ_SCIML_SYSIMAGE)'
error stop 1
end if
print '(a,a,a,a)', 'detonation: julia mode=sysimage image=', trim(image_path), &
' project=', trim(project_path)
call julia_init_ex(image_path=trim(image_path), project_path=trim(project_path))
else
print '(a,a,a)', 'detonation: julia mode=jit project=', trim(project_path)
call julia_init_ex(project_path=trim(project_path))
end if
两条注意事项。镜像按 cpu_target="native" 烘焙——构建主机的指令集——所以它是
单主机产物。而且它钉死在确切的 Julia 构建上:构建镜像与嵌入运行时的必须是同
一个自含 1.12.7 二进制;指向另一个 minor 版本的 shim 会被 sysimage 构建守卫
拒掉(安装)。
sysimage 形态下,镜像已经把预编译模块绑定在 Main 里,于是 JIT 路径需要的那
次运行时 import 根本不发生。capstone 统计的 init/setup 桥调用:sysimage
形态 13 次,JIT 形态 15 次,差的就是那串缺失的 import——import FjKinetics
加上其后的再探测。
形态 3:AOT——Julia 变成一个普通共享库¶
方向在这里翻转:不是 Fortran 嵌入 Julia,而是 PackageCompiler 的
create_library 把一个 Julia 包连同它全部依赖闭包编译进 dist/,把你的函数
暴露成 C 符号。Fortran 像链任何 C 库一样链它;目标机不需要 Julia 安装、仓库、
项目环境。启动就是一次普通的镜像加载(0.58–1.97 s,冷到热缓存),进库的第一
次调用约 12 μs 应答。永远没有 JIT。
这一形态不经过桥,且有一条锋利的边界:SDK 的 julia_init() 不许用。接口注释
摘自 examples/04_performance/1_jit_vs_aot.f90:
! Lifecycle of the runtime embedded in libmylib.so. The SDK's
! julia_init() must NOT be used in this mode: linking libmylib.so
! makes its bundled libjulia satisfy libjulia.so.1 for the whole
! process, and a bare jl_init() would then look for a default
! sys.so that the bundled layout does not ship (the sysimage is
! compiled INTO libmylib.so and only init_julia() knows how to
! load it, with an explicit bindir).
subroutine init_julia(argc, argv) bind(c, name="init_julia")
use iso_c_binding
integer(c_int), value :: argc
type(c_ptr), value :: argv
end subroutine
subroutine shutdown_julia(retcode) bind(c, name="shutdown_julia")
use iso_c_binding
integer(c_int), value :: retcode
end subroutine
同一个首次调用,两个世界(同一份源码,两次构建):
#ifdef COMPILED_MODE
res = my_add_float(10.0d0, 20.0d0)
#else
res_obj = julia%call("perf_add_one", 10.0d0)
res = res_obj%as_real()
#endif
错误处理的形状也变了:任何 Julia 异常都过不了 C ABI。编译库改回整数错误码——
capstone 的 AOT 化学核用 0 表示成功、-1 表示 n 非法、-2 表示求解器尚未建
立、-3 表示求解器抛了——而它的缓冲同样是零拷贝 wrap,与桥的 fcj_wrap_array
如出一辙。
选它之前要知道两笔成本。构建很重:对 capstone 核,整个闭包的非增量 codegen
花了约 50 分钟墙钟、编译器峰值 RSS 约 33 GB,dist/ 交付约 2.5 GB。而且 API
面就此冻结:你调用的是固定的 C 符号,不是运行时按名解析。
一句 PackageCompiler 卫生:用 include_lazy_artifacts=true 构建,否则这个
“自含”库在没有仓库的机器上首次加载时会静默从网络下载惰性 artifact(其中就有
MKL)。
可运行的例子:examples/03_deployment/2_compiled_library/(小演示)与
examples/07_capstone/3_aot_kinetics/(生产形态的那个)。
形态 4:native——丈量的尺¶
纯 Fortran,什么都不嵌入。它在这张表里,因为它是参照物:每种桥形态都要对同一 物理的 native 运行验证——四道验收门、零偏差、残差低于 1e-12 (验证)。当一个工作负载从 Julia 什么都买不到时, native 不是退路;它就是答案。
启动控制:julia_init_ex¶
三种嵌入式形态都从同一个 SDK 调用起步,它包装 C 入口 fcj_init_ex(正如
julia_shutdown 包装 fcj_shutdown)。它的可选参数就是交付旋钮:
image_path(sysimage)、project_path(环境)、nthreads(Julia 线程
数)、handle_signals(信号处置)。摘自
examples/03_deployment/ex_init_options.f90:
! 1. Initialize the Julia runtime with explicit startup options:
! - project_path: omitted on purpose -- the exe-adjacent probe finds
! the FortranBridge package copied next to this executable by the
! build, and an explicit RELATIVE path would be validated against
! the CWD (breaking runs from any other directory)
! - nthreads = 2
! - handle_signals = 0 ("no takeover": SIGINT/SIGTERM are reset to
! SIG_DFL after init; GC-critical handlers stay intact -- bridge.h)
call julia_init_ex(nthreads=2, handle_signals=0)
失败语义是有意设计的:坏选项(塞不下的路径、不存在的环境)不初始化直接返回,
get_last_error_message() 带着原因——进程不会中止。信号方面:桥从不碰
jl_options.handle_signals,因为 GC safepoint 轮询依赖 Julia 自己的处理器;
上面的 0 意为“不接管”:init 后把 SIGINT/SIGTERM 重置回默认。旧的
julia_init / julia_init_with_image 保持其历史信号行为。
选型¶
- 开发用 JIT。 无需构建、可现场重定义、整个栈都经
[compat]resolve。 17.3 s 量级的启动是开发税,交在一台付得起的机器上。 - 同一份二进制要在已知主机上反复跑时,用 sysimage 砍启动。 1.90 s 到首 个结果、堆足迹相同、构造上没有运行时编译。把镜像尺寸算进预算,并记住它钉 主机、钉 Julia 版本。
- 目标机上没有 Julia,或首次调用延迟就是产品时,上 AOT。 无需安装、首次 调用约 12 μs、整数错误码替代异常——外加一场你提前排期、而不是撞上的重型 一次性构建。
- Julia 什么都买不到的地方,留在 native。 基线也是一种形态。