如何通过避免内存分配提升Julia代码性能相关问题
问题1:为什么示例1内存分配远多于示例2?
核心原因是全局变量的类型不稳定性。
示例1中的x是全局变量,Julia的全局变量随时可能被其他代码修改类型,编译器无法在编译sum_global函数时确定x的具体类型,也没法推断循环中i的类型。每次循环取值、做加法的时候都要做运行时类型检查、装箱拆箱操作,这些操作会产生大量临时的堆内存分配,自然开销就上去了。
示例2中x是函数的入参,编译器可以在你第一次调用sum_arg(x)时,根据传入的x的实际类型(这里是Vector{Float64})做完整的类型推断,整个循环逻辑可以直接编译成无运行时开销的原生机器码,不需要额外的类型检查和临时分配,仅有的16字节分配只是返回浮点数的常规开销,所以分配量几乎可以忽略。
问题2:第二次运行更快是不是意味着Julia代码必须跑两次?为什么不做Fortran那样的全预编译?
首先不是必须跑两次,第一次运行慢是Julia的即时编译(JIT)机制导致的:第一次调用某组参数类型的函数时,编译器才会针对这个参数类型生成特化的机器码,后续调用就直接复用编译好的代码,自然速度就快了。如果要避免第一次的编译开销,生产环境可以用预编译注解、或者PackageCompiler工具把整个程序编译成独立二进制,启动就可以直接跑,不存在“必须跑两次”的问题。
至于为什么不采用Fortran的提前全编译(AOT)模式,本质是取舍选择:Julia要同时兼顾开发灵活性和运行性能。Fortran是纯静态语言,所有类型必须提前写死,修改代码后要全量重新编译,开发迭代效率低;而Julia支持交互式开发、动态派发、泛型编程等动态特性,你可以边写代码边调试跑结果,不用每次修改都等全量编译,开发效率高很多,JIT模式刚好可以平衡这种动态性和高性能的需求。
问题3:避免内存分配有没有通用规则?是不是必须每次都用@time定位?
有明确的通用规则,遵守规则可以避免90%以上的不必要内存分配,不需要每次都靠@time排查:
- 尽量不要在核心计算逻辑里用全局变量,必须要用的话给全局变量加
const修饰固定类型 - 保证函数的类型稳定性:函数内变量的类型不要随便变更,返回值类型可以通过输入参数类型唯一推断
- 大数组操作优先用
@views生成视图,不要直接用切片,避免不必要的数组复制 - 循环中要用到的数组提前预分配空间,不要反复做
push!、数组拼接这类会触发扩容重分配的操作 - 尽量用不可变结构体,减少堆上可变对象的创建
当然@time、@btime、@allocated这类工具是用来定位疑难性能问题的,遵守通用规则后性能仍不符合预期,再用工具找具体的分配点就可以。
内容的提问来源于stack exchange,提问作者CRquantum

