From 9d3e4e4c0fb80f5c9f09889f1e9cc3b04ca62d4d Mon Sep 17 00:00:00 2001 From: Tim Besard Date: Fri, 10 Jul 2026 22:03:09 +0200 Subject: [PATCH 1/6] compiler: materialize isbits boxed constants as device globals Since JuliaLang/julia#55045 (1.14.0-DEV.1348), small isbits unions built from constants stay fully boxed, so `julia.constgv` slots may now be dereferenced on device. Instead of baking the host address, emit a constant replica of the box (header word + payload bytes) and point the slot at its payload; LLVM folds the loads. Ghosts, Bools and non-isbits objects keep their identity via baked addresses. `relocate_gvs!` now reports whether the module remained session-portable. Co-Authored-By: Claude Fable 5 --- src/jlgen.jl | 92 +++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 80 insertions(+), 12 deletions(-) diff --git a/src/jlgen.jl b/src/jlgen.jl index 97bd9bff..0f3175b2 100644 --- a/src/jlgen.jl +++ b/src/jlgen.jl @@ -759,32 +759,100 @@ end """ relocate_gvs!(mod::LLVM.Module, gv_to_value::Dict{String, Ptr{Cvoid}}) -Bake absolute pointer values into the initializers of `julia.constgv`-tagged -global variables, matching them by name against `gv_to_value`. Only GVs that -are declarations (as produced by `compile_method_instance`, which strips the -initializers for session portability) or still have a null initializer are -touched: preserving existing initializers covers the older-Julia path where -Julia itself emits pointer values directly. +Resolve `julia.constgv`-tagged global variable slots, matching them by name +against `gv_to_value`. Slots whose object is a non-ghost, non-`Bool` isbits +value are *materialized*: a device-resident constant replica of the box +(header word + payload bytes) is emitted and the slot points at its payload, +so device code can dereference it and LLVM can constant-fold it. All other +slots get the object's absolute host address baked in, as before; device code +only uses those as opaque identity tokens. + +Only GVs that are declarations (as produced by `compile_method_instance`, +which strips the initializers for session portability) or still have a null +initializer are touched: preserving existing initializers covers the +older-Julia path where Julia itself emits pointer values directly. GVs present in `mod` but missing from `gv_to_value` remain declarations, which back-ends will reject loudly (undefined symbol) rather than silently folding to null. + +Returns `true` if the module is session-portable afterwards: no absolute host +address was written (neither a baked slot nor a materialized header carrying +a non-smalltag type pointer). """ function relocate_gvs!(mod::LLVM.Module, gv_to_value::Dict{String, Ptr{Cvoid}}) + portable = true mod_gvs = globals(mod) for (name, init) in gv_to_value haskey(mod_gvs, name) || continue gv = mod_gvs[name] cur = initializer(gv) - if cur === nothing || LLVM.isnull(cur) + if !(cur === nothing || LLVM.isnull(cur)) + # pre-baked by Julia itself (pre-1.13): also session-absolute + portable = false + continue + end + + val = nothing + if init != C_NULL + obj = Base.unsafe_pointer_to_objref(init) + # ghosts/singletons and Bools are compared by pointer; keep their + # identity by baking the host address (they are never dereferenced) + if isbitstype(typeof(obj)) && sizeof(obj) > 0 && !(obj isa Bool) + val, hdr = materialize_box!(mod, gv, obj, init) + # non-smalltag headers carry a host DataType pointer + portable &= hdr < UInt(64 << 4) # jl_max_tags << 4 + end + end + if val === nothing val = const_inttoptr(ConstantInt(Int64(init)), global_value_type(gv)) - initializer!(gv, val) - # re-internalize what compile_method_instance demoted to an external - # declaration; with the address baked in, the optimizer can now fold it - linkage!(gv, LLVM.API.LLVMPrivateLinkage) + portable = false end + initializer!(gv, val) + # re-internalize what compile_method_instance demoted to an external + # declaration; with the value in place, the optimizer can now fold it + linkage!(gv, LLVM.API.LLVMPrivateLinkage) end - return + return portable +end + +# emit a device-resident constant replica of the box holding `obj`; returns +# the constant to store in the slot, and the (gcbits-masked) header word +function materialize_box!(mod::LLVM.Module, gv::GlobalVariable, @nospecialize(obj), + init::Ptr{Cvoid}) + W = sizeof(Int) + hdr, bytes = GC.@preserve obj begin + # the header word transparently yields the smalltag immediate for + # smalltag types and the host type pointer otherwise; drop the gcbits + hdr = unsafe_load(Ptr{UInt}(init - W)) & ~UInt(15) + bytes = [unsafe_load(Ptr{UInt8}(init), i) for i in 1:sizeof(obj)] + hdr, bytes + end + + T_word = LLVM.IntType(8W) + T_byte = LLVM.Int8Type() + fields = LLVM.Constant[ConstantInt(T_word, hdr), ConstantDataArray(T_byte, bytes)] + payload_idx = 1 + if Base.datatype_alignment(typeof(obj)) > W + # pad so the payload lands at a 16-byte offset (JL_HEAP_ALIGNMENT max) + pushfirst!(fields, ConstantDataArray(T_byte, zeros(UInt8, 16 - W))) + payload_idx = 2 + end + boxinit = ConstantStruct(fields) + boxty = value_type(boxinit) + + box = GlobalVariable(mod, boxty, safe_name(LLVM.name(gv)) * "_box") + initializer!(box, boxinit) + constant!(box, true) + linkage!(box, LLVM.API.LLVMPrivateLinkage) + alignment!(box, 16) + unnamed_addr!(box, true) + + idx(i) = ConstantInt(LLVM.Int32Type(), i) + payload = const_gep(boxty, box, LLVM.Constant[idx(0), idx(payload_idx)]) + slotty = global_value_type(gv) + val = value_type(payload) == slotty ? payload : const_addrspacecast(payload, slotty) + return val, hdr end # partially revert JuliaLang/julia#49391 — see #527 From e3ec6138310860ee601cd4fcf773b89b01f1bf3a Mon Sep 17 00:00:00 2001 From: Tim Besard Date: Fri, 10 Jul 2026 22:03:09 +0200 Subject: [PATCH 2/6] driver: only mark jobs session-dependent when addresses were baked Materialized constgv slots (with smalltag headers) contain no session data, so smalltag-only kernels can stay in package images. Co-Authored-By: Claude Fable 5 --- src/driver.jl | 10 +++++----- src/interface.jl | 3 ++- src/rtlib.jl | 11 ++++++----- 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/src/driver.jl b/src/driver.jl index 8c52e884..f153e4e7 100644 --- a/src/driver.jl +++ b/src/driver.jl @@ -334,11 +334,11 @@ const __llvm_initialized = Ref(false) finish_linked_module!(job, ir) - relocate_gvs!(ir, gv_to_value) - - # the IR (and any artifact derived from it) now embeds session-absolute - # addresses; keep results for this job out of a package image - isempty(gv_to_value) || mark_session_dependent!(job) + # resolve `julia.constgv` slots: isbits constants are materialized as + # device-resident globals, everything else gets this session's absolute + # address baked in; only the latter makes the result session-dependent + portable = relocate_gvs!(ir, gv_to_value) + portable || mark_session_dependent!(job) if job.config.optimize @tracepoint "optimization" begin diff --git a/src/interface.jl b/src/interface.jl index 521bc8e0..f17c6e3c 100644 --- a/src/interface.jl +++ b/src/interface.jl @@ -507,7 +507,8 @@ end ## session-dependent results # # Some compilation results embed session-specific data: `relocate_gvs!` bakes absolute -# pointers into the IR of toplevel jobs that reference `julia.constgv` globals, and any +# pointers into the IR of toplevel jobs that reference `julia.constgv` globals (except +# for slots it can materialize as session-portable device constants), and any # artifact a back-end derives from that IR (metallib, SPIR-V, ...) inherits them. Such # results must not survive into a package image, while remaining available for # within-session lookups during the precompilation process itself. Julia wipes its own diff --git a/src/rtlib.jl b/src/rtlib.jl index 9b18bf26..ef608ca0 100644 --- a/src/rtlib.jl +++ b/src/rtlib.jl @@ -101,12 +101,13 @@ function emit_function!(mod, config::CompilerConfig, source::MethodInstance, met # runtime functions may reference Julia objects through `julia.constgv` globals (e.g. # `box_bool` returning the `jl_true`/`jl_false` singletons). Kernels get theirs # relocated when the fully-linked toplevel module is finalized, but the runtime's - # mappings would be dropped along with the rest of its per-function metadata: bake - # the session-absolute addresses into the cached bitcode instead, and keep such - # functions out of package images (their pointers don't survive into other sessions). + # mappings would be dropped along with the rest of its per-function metadata: resolve + # the slots into the cached bitcode instead, and keep functions whose resolution baked + # session-absolute addresses (rather than materialized session-portable constants) + # out of package images. if !isempty(meta.gv_to_value) - relocate_gvs!(new_mod, meta.gv_to_value) - mark_session_dependent!(rt_job) + portable = relocate_gvs!(new_mod, meta.gv_to_value) + portable || mark_session_dependent!(rt_job) end # rename to the final `gpu_*` name on the per-function module, so the cached bitcode From 9f005d8b5e2326c903ac4f03ecf2887dd55a12ef Mon Sep 17 00:00:00 2001 From: Tim Besard Date: Fri, 10 Jul 2026 23:26:17 +0200 Subject: [PATCH 3/6] test: cover boxed constant materialization Co-Authored-By: Claude Fable 5 --- test/native.jl | 90 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/test/native.jl b/test/native.jl index 688c84c1..44bbcd4c 100644 --- a/test/native.jl +++ b/test/native.jl @@ -301,6 +301,96 @@ end end end + @testset "boxed constant materialization" begin + # since JuliaLang/julia#55045, isbits union constants stay fully boxed; the + # box must be replicated on device instead of referencing a host address + mod = @eval module $(gensym()) + union_smalltag(cond::Bool, a::Int32) = cond ? a : Int64(0) + union_float(cond::Bool, a::Float32) = cond ? a : 1.0 + union_bool(cond::Bool, a::Int32) = cond ? a : true + union_ghost(cond::Bool, a::Int32) = cond ? a : nothing + function kernel(p::Ptr{Int64}, cond::Bool, a::Int32) + x = cond ? a : Int64(0) + unsafe_store!(p, Int64(x)) + return + end + function egal_kernel(p::Ptr{Bool}, cond::Bool, a::Int32) + x = cond ? a : Int64(0) + unsafe_store!(p, x === Int64(0)) + return + end + end + + # smalltag constants materialize fully session-portably + ir = sprint(io->Native.code_llvm(io, mod.union_smalltag, Tuple{Bool, Int32}; + dump_module=true, validate=true)) + @static if VERSION >= v"1.14.0-DEV.1348" + @test occursin("_box", ir) + @test !occursin("inttoptr", ir) + end + + # non-smalltag constants carry a host type pointer in the box header, + # but the payload is still device-resident + ir = sprint(io->Native.code_llvm(io, mod.union_float, Tuple{Bool, Float32}; + dump_module=true, validate=true)) + @static if VERSION >= v"1.14.0-DEV.1348" + @test occursin("_box", ir) + end + + # identity objects (Bool singletons, ghosts) are never replicated + ir = sprint(io->Native.code_llvm(io, mod.union_bool, Tuple{Bool, Int32}; + dump_module=true, validate=true)) + @test !occursin("_box", ir) + ir = sprint(io->Native.code_llvm(io, mod.union_ghost, Tuple{Bool, Int32}; + dump_module=true, validate=true)) + @test !occursin("_box", ir) + + # kernel compilation, including bits-egal on the materialized leaf + Native.code_execution(mod.kernel, (Ptr{Int64}, Bool, Int32)) + Native.code_execution(mod.egal_kernel, (Ptr{Bool}, Bool, Int32)) + + # relocate_gvs! reports whether the module stayed session-portable + JuliaContext() do ctx + objs = Any[Int64(42), 1.25, :sym, Int128(1)] + # pointers to the heap boxes rooted in `objs` (passing an element + # through a specialized function would re-box, possibly on the stack) + ptrs = [ccall(:jl_value_ptr, Ptr{Cvoid}, (Any,), x) for x in objs] + function slot_module(ptr::Ptr{Cvoid}) + llvm_mod = LLVM.Module("test") + name = "jl_global#0" + LLVM.GlobalVariable(llvm_mod, LLVM.PointerType(LLVM.Int8Type()), name) + llvm_mod, Dict(name => ptr) + end + GC.@preserve objs begin + # smalltag isbits: materialized, portable + m, map = slot_module(ptrs[1]) + @test GPUCompiler.relocate_gvs!(m, map) + @test haskey(globals(m), "jl_global_0_box") + dispose(m) + + # Float64: materialized, but the header carries a type pointer + m, map = slot_module(ptrs[2]) + @test !GPUCompiler.relocate_gvs!(m, map) + @test haskey(globals(m), "jl_global_0_box") + dispose(m) + + # Symbol: baked address + m, map = slot_module(ptrs[3]) + @test !GPUCompiler.relocate_gvs!(m, map) + @test !haskey(globals(m), "jl_global_0_box") + @test occursin("inttoptr", string(m)) + dispose(m) + + # 16-byte-aligned payloads get padded past the header word + m, map = slot_module(ptrs[4]) + GPUCompiler.relocate_gvs!(m, map) + box = globals(m)["jl_global_0_box"] + @test length(elements(LLVM.global_value_type(box))) == 3 + dispose(m) + end + end + end + @testset "allowed mutable types" begin # when types have no fields, we should always allow them mod = @eval module $(gensym()) From 37a43f0a4e526af1b1b0578cee9e380ca3670e3b Mon Sep 17 00:00:00 2001 From: Tim Besard Date: Fri, 10 Jul 2026 23:55:09 +0200 Subject: [PATCH 4/6] Fix test. --- test/native.jl | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/test/native.jl b/test/native.jl index 44bbcd4c..ce6da8e9 100644 --- a/test/native.jl +++ b/test/native.jl @@ -351,7 +351,11 @@ end # relocate_gvs! reports whether the module stayed session-portable JuliaContext() do ctx - objs = Any[Int64(42), 1.25, :sym, Int128(1)] + # Unlike Int128, vector-shaped tuples are 16-byte aligned on all + # supported architectures and Julia versions. + aligned = (VecElement(Int64(1)), VecElement(Int64(2))) + @test Base.datatype_alignment(typeof(aligned)) > sizeof(Int) + objs = Any[Int64(42), 1.25, :sym, aligned] # pointers to the heap boxes rooted in `objs` (passing an element # through a specialized function would re-box, possibly on the stack) ptrs = [ccall(:jl_value_ptr, Ptr{Cvoid}, (Any,), x) for x in objs] From ae4aba81b4b03ea4b67a1afc5567ca821228a144 Mon Sep 17 00:00:00 2001 From: Tim Besard Date: Tue, 14 Jul 2026 08:11:33 +0200 Subject: [PATCH 5/6] Materialize boxed Bool singletons --- src/driver.jl | 4 +--- src/jlgen.jl | 51 +++++++++++++++++++++++++++++++++++++------------- src/metal.jl | 2 ++ src/rtlib.jl | 9 ++------- test/metal.jl | 25 +++++++++++++++++++++++++ test/native.jl | 47 +++++++++++++++++++++++++++++++++++++++++----- test/ptx.jl | 45 ++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 155 insertions(+), 28 deletions(-) diff --git a/src/driver.jl b/src/driver.jl index f153e4e7..38581053 100644 --- a/src/driver.jl +++ b/src/driver.jl @@ -334,9 +334,7 @@ const __llvm_initialized = Ref(false) finish_linked_module!(job, ir) - # resolve `julia.constgv` slots: isbits constants are materialized as - # device-resident globals, everything else gets this session's absolute - # address baked in; only the latter makes the result session-dependent + # Materialize isbits and Bool boxes; bake addresses for other objects. portable = relocate_gvs!(ir, gv_to_value) portable || mark_session_dependent!(job) diff --git a/src/jlgen.jl b/src/jlgen.jl index 0f3175b2..5aa756ec 100644 --- a/src/jlgen.jl +++ b/src/jlgen.jl @@ -759,31 +759,31 @@ end """ relocate_gvs!(mod::LLVM.Module, gv_to_value::Dict{String, Ptr{Cvoid}}) -Resolve `julia.constgv`-tagged global variable slots, matching them by name -against `gv_to_value`. Slots whose object is a non-ghost, non-`Bool` isbits -value are *materialized*: a device-resident constant replica of the box -(header word + payload bytes) is emitted and the slot points at its payload, -so device code can dereference it and LLVM can constant-fold it. All other -slots get the object's absolute host address baked in, as before; device code -only uses those as opaque identity tokens. +Resolve globals that refer to Julia objects. `jl_true`/`jl_false`, which are +absent from `gv_to_value`, become canonical module-local boxes. Ordinary +non-ghost isbits objects are also materialized; other objects keep their host +address as an opaque identity token. Only GVs that are declarations (as produced by `compile_method_instance`, which strips the initializers for session portability) or still have a null initializer are touched: preserving existing initializers covers the older-Julia path where Julia itself emits pointer values directly. -GVs present in `mod` but missing from `gv_to_value` remain declarations, which -back-ends will reject loudly (undefined symbol) rather than silently folding -to null. +Apart from the dedicated Bool globals, GVs present in `mod` but missing from +`gv_to_value` remain declarations, which back-ends will reject loudly +(undefined symbol) rather than silently folding to null. Returns `true` if the module is session-portable afterwards: no absolute host address was written (neither a baked slot nor a materialized header carrying a non-smalltag type pointer). """ function relocate_gvs!(mod::LLVM.Module, gv_to_value::Dict{String, Ptr{Cvoid}}) - portable = true + portable = materialize_bool_singletons!(mod) mod_gvs = globals(mod) for (name, init) in gv_to_value + # Bools are resolved by name above. + name in ("jl_true", "jl_false") && continue + haskey(mod_gvs, name) || continue gv = mod_gvs[name] cur = initializer(gv) @@ -796,8 +796,7 @@ function relocate_gvs!(mod::LLVM.Module, gv_to_value::Dict{String, Ptr{Cvoid}}) val = nothing if init != C_NULL obj = Base.unsafe_pointer_to_objref(init) - # ghosts/singletons and Bools are compared by pointer; keep their - # identity by baking the host address (they are never dereferenced) + # Zero-sized objects remain identity tokens. if isbitstype(typeof(obj)) && sizeof(obj) > 0 && !(obj isa Bool) val, hdr = materialize_box!(mod, gv, obj, init) # non-smalltag headers carry a host DataType pointer @@ -816,6 +815,32 @@ function relocate_gvs!(mod::LLVM.Module, gv_to_value::Dict{String, Ptr{Cvoid}}) return portable end +# Bool JuliaVariables are absent from `gv_to_value`; define one device box per name. +function materialize_bool_singletons!(mod::LLVM.Module) + portable = true + mod_gvs = globals(mod) + for (name, obj) in ("jl_true" => true, "jl_false" => false) + haskey(mod_gvs, name) || continue + gv = mod_gvs[name] + cur = initializer(gv) + if !(cur === nothing || LLVM.isnull(cur)) + # Existing definitions may contain session-specific addresses. + portable = false + continue + end + + init = ccall(:jl_value_ptr, Ptr{Cvoid}, (Any,), obj) + val, hdr = materialize_box!(mod, gv, obj, init) + initializer!(gv, val) + constant!(gv, true) + linkage!(gv, LLVM.API.LLVMPrivateLinkage) + + # Stay conservative if Bool stops using a smalltag. + portable &= hdr < UInt(64 << 4) # jl_max_tags << 4 + end + return portable +end + # emit a device-resident constant replica of the box holding `obj`; returns # the constant to store in the slot, and the (gcbits-masked) header word function materialize_box!(mod::LLVM.Module, gv::GlobalVariable, @nospecialize(obj), diff --git a/src/metal.jl b/src/metal.jl index 413e4a5b..2e601083 100644 --- a/src/metal.jl +++ b/src/metal.jl @@ -775,6 +775,8 @@ function add_global_address_spaces!(@nospecialize(job::CompilerJob), mod::LLVM.M # delete old globals for (old, new) in global_map prune_constexpr_uses!(old) + # Rewrite constant-expression uses left after cloning. + replace_uses!(old, new) @assert isempty(uses(old)) replace_metadata_uses!(old, new) erase!(old) diff --git a/src/rtlib.jl b/src/rtlib.jl index ef608ca0..b60ba806 100644 --- a/src/rtlib.jl +++ b/src/rtlib.jl @@ -98,13 +98,8 @@ function emit_function!(mod, config::CompilerConfig, source::MethodInstance, met # recent Julia versions include prototypes for all runtime functions, even if unused run!(StripDeadPrototypesPass(), new_mod, llvm_machine(config.target)) - # runtime functions may reference Julia objects through `julia.constgv` globals (e.g. - # `box_bool` returning the `jl_true`/`jl_false` singletons). Kernels get theirs - # relocated when the fully-linked toplevel module is finalized, but the runtime's - # mappings would be dropped along with the rest of its per-function metadata: resolve - # the slots into the cached bitcode instead, and keep functions whose resolution baked - # session-absolute addresses (rather than materialized session-portable constants) - # out of package images. + # Resolve constgv mappings before their metadata is discarded. Dedicated Bool + # globals are resolved after linking into the toplevel module. if !isempty(meta.gv_to_value) portable = relocate_gvs!(new_mod, meta.gv_to_value) portable || mark_session_dependent!(rt_job) diff --git a/test/metal.jl b/test/metal.jl index c1f8b369..763b9376 100644 --- a/test/metal.jl +++ b/test/metal.jl @@ -368,6 +368,31 @@ end end end +@testset "boxed Bool singleton relocation" begin + @static if VERSION >= v"1.14.0-DEV.1348" + mod = @eval module $(gensym()) + @noinline produce(cond::Bool, a::Int32) = cond ? a : true + function consume(cond::Bool, a::Int32) + x = produce(cond, a) + x isa Bool && x && return Int32(1) + return Int32(0) + end + function kernel(ptr, cond::Bool, a::Int32) + unsafe_store!(ptr, consume(cond, a)) + return + end + end + + ir = sprint() do io + Metal.code_llvm(io, mod.kernel, + Tuple{Core.LLVMPtr{Int32,1}, Bool, Int32}; + dump_module=true, kernel=true) + end + @test occursin("@jl_true_box = private unnamed_addr addrspace(2) constant", ir) + @test !occursin("@jl_true = external", ir) + end +end + # Tuples with a dynamic index are lowered to an addrspace(2) constant plus a # GEP+load. Without InferAddressSpaces propagating AS 2 through the cast to # the generic AS introduced during `add_global_address_spaces!`, the load diff --git a/test/native.jl b/test/native.jl index ce6da8e9..2b503e0a 100644 --- a/test/native.jl +++ b/test/native.jl @@ -307,8 +307,19 @@ end mod = @eval module $(gensym()) union_smalltag(cond::Bool, a::Int32) = cond ? a : Int64(0) union_float(cond::Bool, a::Float32) = cond ? a : 1.0 - union_bool(cond::Bool, a::Int32) = cond ? a : true union_ghost(cond::Bool, a::Int32) = cond ? a : nothing + @noinline produce_true(cond::Bool, a::Int32) = cond ? a : true + @noinline produce_false(cond::Bool, a::Int32) = cond ? a : false + function consume_true(cond::Bool, a::Int32) + x = produce_true(cond, a) + x isa Bool && x && return Int32(1) + return Int32(0) + end + function consume_false(cond::Bool, a::Int32) + x = produce_false(cond, a) + x isa Bool && !x && return Int32(1) + return Int32(0) + end function kernel(p::Ptr{Int64}, cond::Bool, a::Int32) x = cond ? a : Int64(0) unsafe_store!(p, Int64(x)) @@ -337,10 +348,19 @@ end @test occursin("_box", ir) end - # identity objects (Bool singletons, ghosts) are never replicated - ir = sprint(io->Native.code_llvm(io, mod.union_bool, Tuple{Bool, Int32}; - dump_module=true, validate=true)) - @test !occursin("_box", ir) + # Boxed Bool leaves use canonical device boxes. + for (f, name) in ((mod.consume_true, "jl_true"), + (mod.consume_false, "jl_false")) + ir = sprint(io->Native.code_llvm(io, f, Tuple{Bool, Int32}; + dump_module=true, validate=true)) + @static if VERSION >= v"1.14.0-DEV.1348" + @test occursin("@$(name)_box = private unnamed_addr constant", ir) + @test !occursin("@$name = external", ir) + @test !occursin("inttoptr", ir) + end + end + + # zero-sized identity objects remain opaque host tokens ir = sprint(io->Native.code_llvm(io, mod.union_ghost, Tuple{Bool, Int32}; dump_module=true, validate=true)) @test !occursin("_box", ir) @@ -365,6 +385,23 @@ end LLVM.GlobalVariable(llvm_mod, LLVM.PointerType(LLVM.Int8Type()), name) llvm_mod, Dict(name => ptr) end + + # Bool JuliaVariables are absent from `gv_to_value`. + m = LLVM.Module("bool singletons") + for name in ("jl_true", "jl_false") + gv = LLVM.GlobalVariable(m, LLVM.PointerType(LLVM.Int8Type()), name) + constant!(gv, true) + end + @test GPUCompiler.relocate_gvs!(m, Dict{String, Ptr{Cvoid}}()) + bool_ir = string(m) + for name in ("jl_true", "jl_false") + @test haskey(globals(m), "$(name)_box") + @test occursin("@$name = private constant", bool_ir) + end + @test !occursin("external", bool_ir) + @test !occursin("inttoptr", bool_ir) + dispose(m) + GC.@preserve objs begin # smalltag isbits: materialized, portable m, map = slot_module(ptrs[1]) diff --git a/test/ptx.jl b/test/ptx.jl index e9269d20..0bddebf4 100644 --- a/test/ptx.jl +++ b/test/ptx.jl @@ -44,6 +44,34 @@ end @test occursin(string(addr), ir) end +@testset "boxed Bool singleton relocation" begin + @static if VERSION >= v"1.14.0-DEV.1348" + mod = @eval module $(gensym()) + @noinline produce_true(cond::Bool, a::Int32) = cond ? a : true + @noinline produce_false(cond::Bool, a::Int32) = cond ? a : false + function consume_true(cond::Bool, a::Int32) + x = produce_true(cond, a) + x isa Bool && x && return Int32(1) + return Int32(0) + end + function consume_false(cond::Bool, a::Int32) + x = produce_false(cond, a) + x isa Bool && !x && return Int32(1) + return Int32(0) + end + end + + for (f, name) in ((mod.consume_true, "jl_true"), + (mod.consume_false, "jl_false")) + ir = sprint(io->PTX.code_llvm(io, f, Tuple{Bool, Int32}; + dump_module=true)) + @test occursin("@$(name)_box = private unnamed_addr constant", ir) + @test !occursin("@$name = external", ir) + @test !occursin("inttoptr", ir) + end + end +end + @testset "kernel functions" begin @testset "kernel argument attributes" begin mod = @eval module $(gensym()) @@ -155,6 +183,23 @@ end if :NVPTX in LLVM.backends() @testset "assembly" begin +@testset "boxed Bool singleton relocation" begin + @static if VERSION >= v"1.14.0-DEV.1348" + mod = @eval module $(gensym()) + @noinline produce(cond::Bool, a::Int32) = cond ? a : true + function consume(cond::Bool, a::Int32) + x = produce(cond, a) + x isa Bool && x && return Int32(1) + return Int32(0) + end + end + ptx = sprint(io->PTX.code_native(io, mod.consume, Tuple{Bool, Int32}; + dump_module=true)) + @test occursin("jl_true_box", ptx) + @test !occursin(r"(?m)^\.extern .*\bjl_true\b", ptx) + end +end + @testset "child functions" begin # we often test using @noinline child functions, so test whether these survive # (despite not having side-effects) From 74386f6888294b860ce294a370f81a1262120f80 Mon Sep 17 00:00:00 2001 From: Tim Besard Date: Tue, 14 Jul 2026 11:24:23 +0200 Subject: [PATCH 6/6] Assert boxed constants are isbits --- src/jlgen.jl | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/jlgen.jl b/src/jlgen.jl index 5aa756ec..62f1f38d 100644 --- a/src/jlgen.jl +++ b/src/jlgen.jl @@ -845,6 +845,8 @@ end # the constant to store in the slot, and the (gcbits-masked) header word function materialize_box!(mod::LLVM.Module, gv::GlobalVariable, @nospecialize(obj), init::Ptr{Cvoid}) + @assert isbitstype(typeof(obj)) && sizeof(obj) > 0 + W = sizeof(Int) hdr, bytes = GC.@preserve obj begin # the header word transparently yields the smalltag immediate for