Every compilation leaks memory: the LLVM context, and on `main` several target machines
维护者通常 1 天内回复
评估
调研方向
Start with the reproducer and trace llvm_machine calls in optimize!, emit_llvm, and mcgen, then inspect the native-code descriptor handling in src/aotcompile.cpp, especially jl_dump_native and the ThreadSafeModule ownership paths. Done means repeated compilation no longer retains target machines, descriptors, modules, or LLVM contexts across the supported Julia versions, with the reproducer confirming the native-memory growth is gone.
由索引模型根据 Issue 内容生成。
描述
Compiling a kernel leaks native memory on every call, and JuliaContext() do ... end doesn't help: its thread-safe context is reference-counted, and the code generator's native-code object holds a second reference to it. Since that object is never freed (#756), the context and everything in it stay alive. I measured this while porting GPUCompiler to LLVM.jl 10.
Measurements
The loop compiles the same trivial kernel (kernel(x) = x + 1, NativeCompilerTarget, kernel=false) inside a fresh JuliaContext() do ... end each time, and reports RSS growth after GC.gc(true) (the Julia heap stays flat, so the growth is native memory). The script is attached at the end.
| per compilation | |
|---|---|
main (LLVM.jl 9.13.2), Julia 1.12, compile(:asm) |
~1380 KiB |
same, after disposing of meta.ir |
~1375 KiB |
same, after also freeing the LLVM context by hand (LLVMContextDispose) |
~1285 KiB |
main, Julia 1.12, compile(:llvm) |
~565 KiB |
| same, after freeing the context by hand | ~470 KiB |
LLVM.jl 10 port (tb/llvm10), Julia 1.12, compile(:asm) |
~36 KiB |
| same, after freeing the context by hand | 0 |
port, Julia 1.10 / 1.13, compile(:asm) |
~91 / ~86 KiB |
| same, after freeing the context by hand | ~23 / ~16 KiB |
That freeing the context "by hand" after JuliaContext returned doesn't crash already shows that the context wasn't freed when JuliaContext disposed of its ThreadSafeContext.
So there are two leaks on main:
- Target machines.
llvm_machine(job.config.target)creates a newTargetMachinethat nobody disposes of. It's called several times per compilation (the optimization pipeline inoptimize!/emit_llvmandmcgen), which accounts for most of the ~1.3 MiB. The LLVM.jl 10 port (tb/llvm10) disposes of them (through awith_llvm_machine(target) do tm ... endhelper), so this one goes away with it. - The context, which is what remains on the port: about 35–70 KiB per compilation for a trivial kernel, more for bigger ones (it holds all types, constants and metadata, and any module that wasn't disposed of).
Why the context survives
JuliaContext() creates a fresh ThreadSafeContext for every compilation, which is a shared_ptr to the LLVMContext. Disposing of it only drops our reference. In compile_method_instance, we pass a thread-safe module in that context to the code generator:
- Up to Julia 1.13,
jl_emit_native/jl_create_nativemove it into the native-code descriptor:data->M = std::move(clone)(src/aotcompile.cpp). A movedorc::ThreadSafeModulecarries itsThreadSafeContext, i.e. another reference to the context. - That descriptor is only freed by
jl_dump_native(delete data), which we never call (#756). So the descriptor, itsThreadSafeModuleand therefore the context live forever. - On
main,meta.iris the module inside that descriptor (borrowed throughjl_get_llvm_module), so it's never freed either. Disposing ofmeta.irfrees the module, but not the context. - On Julia 1.14, the descriptor only keeps a pointer to our thread-safe module (
data->TSM_ref). Onmainthat one is never disposed of, so the context leaks the same way. The port disposes of it after taking the module out withLLVM.unsafe_take_module!, which is why the context is freed there on 1.14. Usingmeta.irafterJuliaContextreturns is a use-after-free on 1.14 (it segfaults); before, it worked because of the leak.
The port changes how the module is handed out, but not the context leak on ≤ 1.13: it takes the module out of the descriptor with LLVM.unsafe_take_module! (LLVM 16+), so callers own meta.ir, but the now-empty ThreadSafeModule in the descriptor still references the context.
Possible fixes
- Free the descriptor once we're done querying it (the global variable tables, function IDs, ...). There's no dedicated API, but
jl_dump_nativewith no outputs deletes it (if (!bc_fname && !unopt_bc_fname && !obj_fname && !asm_fname) { delete data; return; }, present on 1.10–1.14; its signature differs: 1.10/1.11 have noparamsargument). That relies on an implementation detail, so ajl_free_native-style entry point in Julia would be better. With the port, this would free the context whenJuliaContextreturns on 1.11+. - Julia 1.10 (LLVM 15) can't move the module out of a thread-safe module, so there
meta.iris the descriptor's module, and freeing the descriptor would free it. That needs either a copy of the module or keeping the leak on 1.10. - Alternatively, empty the descriptor's
ThreadSafeModule(assign it a default-constructed one) to drop its context reference, which would need a small LLVMExtra function.
For now, the port keeps meta.ir as is: callers get the module and may dispose of it, and the IR must not be used after the JuliaContext it was compiled in.
Reproducer
# usage: julia --project leak.jl <drop|dispose|killctx> <N> <llvm|asm>
using GPUCompiler, LLVM
module TestRuntime
signal_exception() = return
malloc(sz) = C_NULL
report_oom(sz) = return
report_exception(ex) = return
report_exception_name(ex) = return
report_exception_frame(idx, func, file, line) = return
end
struct TestParams <: GPUCompiler.AbstractCompilerParams end
GPUCompiler.runtime_module(::GPUCompiler.CompilerJob{<:Any,TestParams}) = TestRuntime
kernel(x) = x + 1
function mkjob()
source = GPUCompiler.methodinstance(typeof(kernel), Tuple{Int})
config = GPUCompiler.CompilerConfig(GPUCompiler.NativeCompilerTarget(), TestParams(); kernel=false)
GPUCompiler.CompilerJob(source, config)
end
rss() = parse(Int, split(read("/proc/self/statm", String))[2]) * 4096 / 2^20
variant = ARGS[1]; N = parse(Int, ARGS[2]); stage = Symbol(get(ARGS, 3, "asm"))
function once()
ref = JuliaContext() do ctx
asm, meta = GPUCompiler.compile(stage, mkjob())
variant in ("dispose", "killctx") && LLVM.dispose(stage == :llvm ? asm : meta.ir)
ctx.ref
end
# experiment: free the LLVM context behind the ThreadSafeContext's back
variant == "killctx" && LLVM.API.LLVMContextDispose(ref)
end
for i in 1:50; once(); end; GC.gc(true)
r0 = rss()
for i in 1:N; once(); end; GC.gc(true)
r1 = rss()
println("$variant $stage: $(round((r1-r0)*1024/N; digits=1)) KiB per compilation")
- 主要语言
- Julia
- 星标
- 188
- 派生
- 71
- 平均合并
- 1 天 13 小时
- 30 天内合并 PR
- 68
环境准备
这个项目没有提供开发容器、Dockerfile 或贡献指南,环境需要你自己搭建:先看它的 README,通用步骤见我们的新手贡献指南。
从这里开始
- 先读完整个 Issue,再读项目的贡献指南。
- 在 Issue 下留言说明你要接手 —— 这能避免两个人做同样的事。
- Fork 仓库,在一个分支上完成修改。
- 提交 Pull Request,并在描述里引用这个 Issue 编号。
JuliaGPU/GPUCompiler.jl 的其他 Issue
-
spirv
难度 2/5 1-3 小时 新手友好度 68/100
JuliaGPU/GPUCompiler.jl#1008 ·
维护者通常 1 天内回复
-
难度 4/5 1-2 天 新手友好度 48/100
JuliaGPU/GPUCompiler.jl#1032 ·
维护者通常 1 天内回复
-
难度 4/5 3-5 天 新手友好度 48/100
JuliaGPU/GPUCompiler.jl#1031 ·
维护者通常 1 天内回复
-
难度 4/5 3-5 天 新手友好度 48/100
JuliaGPU/GPUCompiler.jl#1030 ·
维护者通常 1 天内回复
-
gcn latency
难度 5/5 一周以上 新手友好度 10/100
JuliaGPU/GPUCompiler.jl#1014 ·
维护者通常 1 天内回复
查看 JuliaGPU/GPUCompiler.jl 的全部 Issue
相似的 Issue
-
难度 2/5 1-3 小时 新手友好度 83/100
JuliaGenAI/HuggingFaceDatasets.jl#96 ·
维护者通常 1 天内回复
-
难度 2/5 1-3 小时 新手友好度 78/100
-
难度 2/5 1-3 小时 新手友好度 83/100
维护者通常 1 天内回复
-
enhancement
难度 2/5 1-3 小时 新手友好度 76/100
-
难度 2/5 1-3 小时 新手友好度 66/100
JuliaData/DBFTables.jl#50 ·