Summary
On Julia 1.12 at -O2, the reproducer below segfaults while the GC is marking an object. Julia 1.13.0-rc1 is affected as well (identical crash signature); 1.11 and current master are not.
The value selected by the ternary has a union of two concrete tuple types. Julia 1.12 heap-boxes each possible branch-result tuple, and at -O2 the initialization of the first tuple field — which contains several GC pointers — is eliminated after the box has been placed in a GC root, because only the second field is ever read. A subsequent allocation is a GC safepoint; if GC runs there, it scans the uninitialized bytes (stale pool contents) as pointers and crashes in gc_mark_obj8.
Reproducer
Crash typically occurs within seconds to a few minutes (it is nondeterministic, since it depends on recycled pool contents). Adding --heap-size-hint=64M makes it crash reliably within ~2 seconds (~5 GC cycles).
struct LogP; v::Float64; end
struct LogL; v::Float64; end
struct GetRaw end
struct VNT{T}; nt::T; end
struct RawAcc{F,V}; f::F; flag::Bool; vnt::V; end
struct OAVI{A}; accs::A; end
const VNTIn = VNT{NamedTuple{(:s, :m),Tuple{VNT{NamedTuple{(:p,),Tuple{Vector{Float64}}}},Vector{Float64}}}}
@noinline mkvnt() = VNT((s=VNT((p=fill(0.5, 2),)), m=zeros(2)))::VNTIn
@noinline function runmodelA(n::Int)
nt = (s=fill(1.0, n), m=view(fill(2.0, n), 1:2), x=fill(3.0, n))
vi = OAVI((LogPrior=LogP(1.0), RawValues=RawAcc(GetRaw(), true, mkvnt())))
return (nt, vi)
end
@noinline function runmodelB(n::Int)
nt = (s=fill(1.0, n), m=view(fill(2.0, n), 1:2), x=fill(3.0, n))
vi = OAVI((
LogPrior=LogP(1.0), LogLikelihood=LogL(2.0), RawValues=RawAcc(GetRaw(), true, mkvnt())
))
return (nt, vi)
end
get_raw(vi::OAVI) = vi.accs.RawValues.vnt
@noinline dens(v::VNTIn) = length(v.nt.m)
@noinline lpof(vi::OAVI) = vi.accs.LogPrior.v
@noinline function step(fl::Bool, n::Int)
t = fl ? runmodelA(n) : runmodelB(n)
_, vi = t
x = dens(get_raw(vi))
return x + lpof(vi)
end
# Keep recycled pool pages populated with stale non-null values.
function churn(bag, k)
for i in 1:k
push!(bag, (i, i + 1))
push!(bag, view(rand(4), 1:2))
push!(bag, (true, true, true))
end
length(bag) > 200_000 && empty!(bag)
return nothing
end
function main()
bag = Any[]
acc = 0.0
it = 0
while true
it += 1
acc += step(true, 4)
acc += step(false, 4)
it % 50 == 0 && churn(bag, 100)
end
end
main()
Observed result
Reproduced on the official Julia 1.12.6 Linux x86_64 binary (3/3 runs, e.g. after 26.7M / 34.5M allocations; 4.6M allocations with --heap-size-hint=64M) and on the official 1.13.0-rc1 binary (8.6M allocations, 8 GCs):
signal 11 (1): Segmentation fault
in expression starting at repro.jl:56
gc_mark_obj8 at src/gc-stock.c:1704 [inlined]
gc_mark_outrefs at src/gc-stock.c:2463 [inlined]
gc_mark_loop_serial_ at src/gc-stock.c:2519
gc_mark_loop_serial at src/gc-stock.c:2542
gc_mark_loop at src/gc-stock.c:2730 [inlined]
_jl_gc_collect at src/gc-stock.c:3074
ijl_gc_collect at src/gc-stock.c:3475
maybe_collect at src/gc-stock.c:351 [inlined]
jl_gc_small_alloc_inner at src/gc-stock.c:727 [inlined]
ijl_gc_small_alloc at src/gc-stock.c:776
step at repro.jl:28
main at repro.jl:52
Allocations: 26767329 (Pool: 26767323; Big: 6); GC: 25
The exact number of iterations is nondeterministic because the crash depends on recycled pool contents.
Expected result
No crash. Any object made visible to the GC should have all GC-scanned pointer fields initialized before a safepoint, even when the corresponding Julia-level field is dead.
Affected versions
| Version |
Flags |
Result |
| 1.12.6 (official binary) |
-O2 |
segfault in gc_mark_obj8 (3/3 runs) |
| 1.12.6 |
-O2 --heap-size-hint=64M |
segfault in ~2 s, ~5 GC cycles |
| 1.13.0-rc1 (official binary) |
-O2 |
segfault, same signature |
| 1.12.6 |
-O1 |
clean (90 s / 2.7B allocations / 2304 GCs) |
| 1.11.9 |
-O2 |
clean (90 s / 8.6B allocations / 6204 GCs) |
| 1.14.0-DEV nightly |
-O2 |
clean (90 s / 11.5B allocations / 12272 GCs) |
LLVM evidence
On Julia 1.12.6 at -O2, the relevant portion of optimized LLVM IR for step (A branch) is:
; Allocate an 88-byte Tuple object (osize 96).
%box.Tuple = call ptr @ijl_gc_small_alloc(..., i32 96, ...)
; Install its tag.
store atomic i64 <tuple type>, ptr %box.Tuple.tag_addr unordered
; Only initialize bytes 56 through 87, corresponding to tuple field 2.
%src = getelementptr i8, ptr %sret_box, i64 56
%dst = getelementptr i8, ptr %box.Tuple, i64 56
call void @llvm.memcpy.p0.p0.i64(ptr %dst, ptr %src, i64 16, ...)
store atomic ptr %p1, ptr %box.Tuple.plus72 unordered
store atomic ptr %p2, ptr %box.Tuple.plus80 unordered
; Root the tuple while bytes 0 through 55 remain uninitialized.
store ptr %box.Tuple, ptr %gc_slot
; Subsequent allocation / possible GC safepoint.
%box.OAVI = call ptr @ijl_gc_small_alloc(..., i32 48, ...)
There are no stores to bytes 0 through 55 of the tuple object. Those bytes are the dead first tuple element (a NamedTuple with GC pointers at box offsets 0, 8, and 48). The B branch shows the same pattern (osize-112 box, nothing written to bytes 0–55).
At -O1, the heap tuple is fully initialized before it is rooted. On Julia 1.11.9 at -O2, the branch-result tuple return buffers remain stack allocas and are not scanned as GC objects. On master, the union result uses stack storage (sret allocas plus a union alloca), so there is no heap box at all.
Root cause: likely Julia codegen/pipeline, not LLVM
The raw, pre-optimization IR (code_llvm(optimize=false)) shows that Julia's codegen does emit full initialization of the box (per-field stores covering bytes 0–87). The initialization is then removed by Julia's own pass pipeline, and the removal is legal under the memory-effect attributes Julia itself declares: ijl_gc_small_alloc is declared
attributes #11 = { nounwind willreturn allockind("alloc") allocsize(2) memory(argmem: read, inaccessiblemem: readwrite) }
i.e. the call "cannot" read regular heap memory such as the rooted box. Once Julia's passes fold the julia.typeof guards (the box's only other escape) and FinalLowerGCPass inserts the root store, the only real-world reader of those bytes is GC marking inside the alloc call — which the attributes deny. The stores are unread, hence dead, hence eliminable. As a control, stock LLVM 18.1.3 opt -passes='default<O2>' on Julia's raw IR module preserves the full initialization (stock LLVM cannot fold julia.typeof, so the box stays escaped). No LLVM miscompilation is involved.
Potential regression window: two interacting 1.12-cycle changes
The crash requires two changes, both verified (via git merge-base --is-ancestor) to be in release-1.12 and absent from release-1.11. Neither alone is sufficient.
1. PR #55767 — "codegen: split gc roots from other bits on stack" (commit 25cbe006f)
changed the immutable representation to <packed-data, roots> pairs. As a side effect, the boxed-union initialization is emitted as per-field atomic root stores plus packed-data memcpys instead of a single whole-object memcpy. With this store pattern, AllocOpt's escape analysis concludes has_refaggr and refuses heap→stack promotion. Observed via --pass-remarks=alloc-opt:
- 1.11:
MemOps: 0 → "GC allocation moved to stack" (box promoted; nothing GC-scanned)
- 1.12: "GC allocation has unusual object reference, unable to move to stack" (box stays on the heap)
2. PR #56847 — "optimize constant length memorynew intrinsic (take 2)" (commit 7801351fb)
added a single line to src/pipeline.cpp, a DSEPass() in the O.getSpeedupLevel() >= 2 block immediately after FinalLowerGCPass():
JULIA_PASS(FPM.addPass(FinalLowerGCPass()));
if (O.getSpeedupLevel() >= 2) {
FPM.addPass(DSEPass()); // ← added; removes the dead-field init after rooting
...
This explains the -O1/-O2 split exactly (1.11 had only GVN/SCCP/DCE at that point in the pipeline).
Note that PR #52850 (in 1.13, not 1.12) moved this DSE later in the pipeline (after RemoveJuliaAddrspaces), which does not address the root issue — consistent with 1.13.0-rc1 still crashing. Master no longer crashes because PR #55045 (sret_union ABI for pointer-ful types, merged 2025-12-08, merge commit 2264f50) stack-allocates the union result; that commit is not reachable from release-1.13 and is not in the 1.13.0-rc2 backport batch (#61987).
Possible additional impact
The AllocOpt promotion failure from #55767 likely also regresses performance for this pattern independent of the crash: union results that 1.11 stack-promoted now heap-allocate on 1.12/1.13 at -O2.
Version information
Julia Version 1.12.6
Commit 15346901f00 (2026-04-09 19:20 UTC)
Build Info:
Official https://julialang.org release
Platform Info:
OS: Linux (x86_64-linux-gnu)
CPU: Intel(R) Xeon(R) Processor @ 2.60GHz
WORD_SIZE: 64
LLVM: libLLVM-18.1.7 (ORCJIT, icelake-server)
GC: Built with stock GC
Threads: 1 default, 1 interactive, 1 GC
(Independently reproduced on a second x86_64 Linux machine, official 1.12.6 and 1.13.0-rc1 binaries via juliaup.)
originally discovered by @yebai
report written with Kimi-K3, reproduced by GPT-5.6-Sol and Kimi-K3
Summary
On Julia 1.12 at
-O2, the reproducer below segfaults while the GC is marking an object. Julia 1.13.0-rc1 is affected as well (identical crash signature); 1.11 and current master are not.The value selected by the ternary has a union of two concrete tuple types. Julia 1.12 heap-boxes each possible branch-result tuple, and at
-O2the initialization of the first tuple field — which contains several GC pointers — is eliminated after the box has been placed in a GC root, because only the second field is ever read. A subsequent allocation is a GC safepoint; if GC runs there, it scans the uninitialized bytes (stale pool contents) as pointers and crashes ingc_mark_obj8.Reproducer
Crash typically occurs within seconds to a few minutes (it is nondeterministic, since it depends on recycled pool contents). Adding
--heap-size-hint=64Mmakes it crash reliably within ~2 seconds (~5 GC cycles).Observed result
Reproduced on the official Julia 1.12.6 Linux x86_64 binary (3/3 runs, e.g. after 26.7M / 34.5M allocations; 4.6M allocations with
--heap-size-hint=64M) and on the official 1.13.0-rc1 binary (8.6M allocations, 8 GCs):The exact number of iterations is nondeterministic because the crash depends on recycled pool contents.
Expected result
No crash. Any object made visible to the GC should have all GC-scanned pointer fields initialized before a safepoint, even when the corresponding Julia-level field is dead.
Affected versions
-O2gc_mark_obj8(3/3 runs)-O2 --heap-size-hint=64M-O2-O1-O2-O2LLVM evidence
On Julia 1.12.6 at
-O2, the relevant portion of optimized LLVM IR forstep(A branch) is:There are no stores to bytes 0 through 55 of the tuple object. Those bytes are the dead first tuple element (a
NamedTuplewith GC pointers at box offsets 0, 8, and 48). The B branch shows the same pattern (osize-112 box, nothing written to bytes 0–55).At
-O1, the heap tuple is fully initialized before it is rooted. On Julia 1.11.9 at-O2, the branch-result tuple return buffers remain stackallocas and are not scanned as GC objects. On master, the union result uses stack storage (sretallocas plus a union alloca), so there is no heap box at all.Root cause: likely Julia codegen/pipeline, not LLVM
The raw, pre-optimization IR (
code_llvm(optimize=false)) shows that Julia's codegen does emit full initialization of the box (per-field stores covering bytes 0–87). The initialization is then removed by Julia's own pass pipeline, and the removal is legal under the memory-effect attributes Julia itself declares:ijl_gc_small_allocis declaredi.e. the call "cannot" read regular heap memory such as the rooted box. Once Julia's passes fold the
julia.typeofguards (the box's only other escape) andFinalLowerGCPassinserts the root store, the only real-world reader of those bytes is GC marking inside the alloc call — which the attributes deny. The stores are unread, hence dead, hence eliminable. As a control, stock LLVM 18.1.3opt -passes='default<O2>'on Julia's raw IR module preserves the full initialization (stock LLVM cannot foldjulia.typeof, so the box stays escaped). No LLVM miscompilation is involved.Potential regression window: two interacting 1.12-cycle changes
The crash requires two changes, both verified (via
git merge-base --is-ancestor) to be inrelease-1.12and absent fromrelease-1.11. Neither alone is sufficient.1. PR #55767 — "codegen: split gc roots from other bits on stack" (commit
25cbe006f)changed the immutable representation to
<packed-data, roots>pairs. As a side effect, the boxed-union initialization is emitted as per-field atomic root stores plus packed-data memcpys instead of a single whole-object memcpy. With this store pattern, AllocOpt's escape analysis concludeshas_refaggrand refuses heap→stack promotion. Observed via--pass-remarks=alloc-opt:MemOps: 0→ "GC allocation moved to stack" (box promoted; nothing GC-scanned)2. PR #56847 — "optimize constant length
memorynewintrinsic (take 2)" (commit7801351fb)added a single line to
src/pipeline.cpp, aDSEPass()in theO.getSpeedupLevel() >= 2block immediately afterFinalLowerGCPass():This explains the
-O1/-O2split exactly (1.11 had only GVN/SCCP/DCE at that point in the pipeline).memorynewintrinsic (take 2) #56847: heap box, but fully initialized — safe.memorynewintrinsic (take 2) #56847 without codegen: split gc roots from other bits on stack #55767: box stack-promoted as in 1.11 — safe.Note that PR #52850 (in 1.13, not 1.12) moved this DSE later in the pipeline (after
RemoveJuliaAddrspaces), which does not address the root issue — consistent with 1.13.0-rc1 still crashing. Master no longer crashes because PR #55045 (sret_unionABI for pointer-ful types, merged 2025-12-08, merge commit2264f50) stack-allocates the union result; that commit is not reachable fromrelease-1.13and is not in the 1.13.0-rc2 backport batch (#61987).Possible additional impact
The AllocOpt promotion failure from #55767 likely also regresses performance for this pattern independent of the crash: union results that 1.11 stack-promoted now heap-allocate on 1.12/1.13 at
-O2.Version information
(Independently reproduced on a second x86_64 Linux machine, official 1.12.6 and 1.13.0-rc1 binaries via juliaup.)
originally discovered by @yebai
report written with Kimi-K3, reproduced by GPT-5.6-Sol and Kimi-K3