Hacktoberfest 2026: le issue che i maintainer hanno segnato per ottobre, aperte e adatte ai principianti. Sfoglia le issue Hacktoberfest

Every compilation leaks memory: the LLVM context, and on `main` several target machines

Aperta
#970 0 commenti 0 reazioni 0 assegnatari Vedi su GitHub

I maintainer di solito rispondono entro 1 giorno

Nessuno ha ancora preso questa issue.

Valutazione

Difficoltà
5/5
Tempo stimato
Più di una settimana
Idoneità per principianti
35/100
Tipo di issue
Bug
Chiarezza
Da chiarire
Stato di attività
Attiva
Stack tecnologico
julia
Ambito
compilers

Direzione di ricerca

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.

Scritto dal modello di indicizzazione a partire dal testo della issue.

Descrizione

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:

  1. Target machines. llvm_machine(job.config.target) creates a new TargetMachine that nobody disposes of. It's called several times per compilation (the optimization pipeline in optimize!/emit_llvm and mcgen), which accounts for most of the ~1.3 MiB. The LLVM.jl 10 port (tb/llvm10) disposes of them (through a with_llvm_machine(target) do tm ... end helper), so this one goes away with it.
  2. 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_native move it into the native-code descriptor: data->M = std::move(clone) (src/aotcompile.cpp). A moved orc::ThreadSafeModule carries its ThreadSafeContext, 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, its ThreadSafeModule and therefore the context live forever.
  • On main, meta.ir is the module inside that descriptor (borrowed through jl_get_llvm_module), so it's never freed either. Disposing of meta.ir frees the module, but not the context.
  • On Julia 1.14, the descriptor only keeps a pointer to our thread-safe module (data->TSM_ref). On main that one is never disposed of, so the context leaks the same way. The port disposes of it after taking the module out with LLVM.unsafe_take_module!, which is why the context is freed there on 1.14. Using meta.ir after JuliaContext returns 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_native with 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 no params argument). That relies on an implementation detail, so a jl_free_native-style entry point in Julia would be better. With the port, this would free the context when JuliaContext returns on 1.11+.
  • Julia 1.10 (LLVM 15) can't move the module out of a thread-safe module, so there meta.ir is 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")
Lingua principale
Julia
Stelle
188
Fork
71
Merge medio
1g 14h
PR unite (30g)
65

Preparare l'ambiente

Questo progetto non fornisce container di sviluppo, Dockerfile né guida per i contributori, quindi l'ambiente è a tuo carico: parti dal suo README e consulta la nostra guida al primo contributo per i passaggi generali.

Come iniziare

  1. Leggi tutta la issue e poi la guida ai contributi del progetto.
  2. Commenta sulla issue per dire che te ne occupi tu — evita che due persone facciano lo stesso lavoro.
  3. Fai un fork del repository e lavora su un branch.
  4. Apri una pull request che faccia riferimento al numero della issue.

Altre issue di JuliaGPU/GPUCompiler.jl

Tutte le issue di JuliaGPU/GPUCompiler.jl

Issue simili

Altre issue su Julia

Ricevi le nuove issue nella tua casella

Un breve riepilogo di issue GitHub adatte ai principianti.