Skip to content

Commit 78eaff3

Browse files
vchuravyclaude
andauthored
Add alloca intrinsic for per-workitem stack scratch (#859)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 8559d5b commit 78eaff3

3 files changed

Lines changed: 219 additions & 0 deletions

File tree

src/irgen.jl

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,10 @@ function irgen(@nospecialize(job::CompilerJob))
149149
# the job's configured level, so device code can branch on it as a compile-time
150150
# constant that is part of the cache key (unlike reading the `-g` global directly).
151151
lower_debug_level!(job, mod)
152+
153+
# materialize `GPUCompiler.alloca` intrinsics as real entry-block allocas, before the
154+
# optimizer runs so the slots can be promoted (see `lower_alloca!`).
155+
lower_alloca!(job, mod)
152156
end
153157

154158
return mod, compiled, gv_to_value
@@ -1216,6 +1220,138 @@ function lower_debug_level!(@nospecialize(job::CompilerJob), mod::LLVM.Module)
12161220
return true
12171221
end
12181222

1223+
1224+
## stack allocation
1225+
1226+
# device code can request a fixed-size, per-workitem stack scratch buffer via
1227+
# `alloca(T, Val(N), Val(AS))`, returning an `LLVMPtr{T,AS}` to uninitialized storage for `N`
1228+
# elements of `T` in address space `AS`. this emits a call to the `julia.gpu.alloca` intrinsic
1229+
# with the size and alignment as constant operands, which `lower_alloca!` (run from `irgen`,
1230+
# before the optimizer) materializes as a real entry-block `alloca`.
1231+
#
1232+
# this exists because emitting an `alloca` directly through `llvmcall` is unsound/ineffective:
1233+
# the `Ptr` round-trip through `ptrtoint`/`inttoptr` blocks SROA/mem2reg promotion, the target
1234+
# stack address space (e.g. AS 5 on NVPTX/AMDGPU) isn't known at the front-end, and the
1235+
# LangRef lifetime of an `alloca` is tied to the (inlined) `llvmcall` wrapper. lowering it
1236+
# ourselves lets us place the slot in the kernel entry block, in the datalayout's alloca
1237+
# address space, early enough for the optimizer to promote it.
1238+
1239+
function alloca_intr(mod::LLVM.Module, T_ptr::LLVMType)
1240+
name = "julia.gpu.alloca"
1241+
intr = if haskey(functions(mod), name)
1242+
functions(mod)[name]
1243+
else
1244+
# takes the size in bytes and the alignment as constant operands, and returns a
1245+
# pointer in the requested address space; intentionally *not* readnone/speculatable,
1246+
# as each call must yield a distinct slot and must not be hoisted or CSE'd. a module
1247+
# only ever targets a single address space, so one declaration suffices.
1248+
T_i64 = LLVM.Int64Type()
1249+
LLVM.Function(mod, name, LLVM.FunctionType(T_ptr, [T_i64, T_i64]))
1250+
end
1251+
return intr
1252+
end
1253+
1254+
# run-time equivalent: emits a call to the alloca intrinsic, returning an `LLVMPtr{T,AS}` to
1255+
# scratch storage for `N` elements of `T` in address space `AS` (materialized by
1256+
# `lower_alloca!`).
1257+
function alloca_value(@nospecialize(T), N::Int, AS::Int)
1258+
isbitstype(T) ||
1259+
error("GPUCompiler.alloca only supports `isbits` element types, got $T")
1260+
N >= 0 || throw(ArgumentError("GPUCompiler.alloca count must be non-negative, got $N"))
1261+
1262+
bytes = sizeof(T) * N
1263+
align = Base.datatype_alignment(T)
1264+
1265+
# a zero-byte allocation has no storage to point at; hand back a null pointer rather than
1266+
# emitting a degenerate 0-element alloca.
1267+
if bytes == 0
1268+
return :(reinterpret(Core.LLVMPtr{$T,$AS}, C_NULL))
1269+
end
1270+
1271+
@dispose ctx=Context() begin
1272+
# `LLVMPtr{T,AS}` lowers to an (i8/opaque) pointer in address space `AS`; match that
1273+
# as the intrinsic's return type so the `llvmcall` boundary type-checks.
1274+
T_ptr = convert(LLVMType, Core.LLVMPtr{T,AS})
1275+
1276+
# create function
1277+
llvm_f, _ = create_function(T_ptr)
1278+
mod = LLVM.parent(llvm_f)
1279+
1280+
# get intrinsic
1281+
intr = alloca_intr(mod, T_ptr)
1282+
intr_ft = function_type(intr)
1283+
1284+
# generate IR
1285+
@dispose builder=IRBuilder() begin
1286+
entry = BasicBlock(llvm_f, "entry")
1287+
position!(builder, entry)
1288+
1289+
args = Value[ConstantInt(LLVM.Int64Type(), bytes),
1290+
ConstantInt(LLVM.Int64Type(), align)]
1291+
ptr = call!(builder, intr_ft, intr, args, "alloca")
1292+
1293+
ret!(builder, ptr)
1294+
end
1295+
1296+
call_function(llvm_f, Core.LLVMPtr{T,AS})
1297+
end
1298+
end
1299+
1300+
# device-facing accessor: an `LLVMPtr{T,AS}` to per-workitem stack scratch for `N` elements of
1301+
# `T` in address space `AS`. the storage is uninitialized and only valid within the calling
1302+
# kernel. `T` must be `isbits` (an `alloca` of GC-tracked references would be unrooted).
1303+
# intended as a building block for higher-level scratch abstractions (e.g. KernelAbstractions'
1304+
# `@private`).
1305+
@inline @generated alloca(::Type{T}, ::Val{N}, ::Val{AS}) where {T,N,AS} = alloca_value(T, N, AS)
1306+
1307+
# pick the element type for a `bytes`-sized, `align`-aligned stack slot. rather than a flat
1308+
# `[bytes x i8]`, emit aligned integer chunks: SROA takes a hint from the element type and
1309+
# will happily shred an i8 array into unaligned scalars (terrible for vectorization), whereas
1310+
# an element size equal to the alignment makes it split into aligned pieces instead. the
1311+
# element size is capped at 64 bits since not all back-ends support wider integers. mirrors
1312+
# Julia's `emit_static_alloca` (src/codegen.cpp).
1313+
function alloca_slot_type(bytes::Integer, align::Integer)
1314+
elsize = min(align, 8)
1315+
padded = cld(bytes, elsize) * elsize
1316+
eltyp = LLVM.IntType(elsize * 8)
1317+
# a single element covers the whole slot; don't bother wrapping it in a length-1 array.
1318+
return padded == elsize ? eltyp : LLVM.ArrayType(eltyp, padded ÷ elsize)
1319+
end
1320+
1321+
# replace every `julia.gpu.alloca` call with an entry-block alloca in the containing function
1322+
function lower_alloca!(@nospecialize(job::CompilerJob), mod::LLVM.Module)
1323+
haskey(functions(mod), "julia.gpu.alloca") || return false
1324+
intr = functions(mod)["julia.gpu.alloca"]
1325+
1326+
@dispose builder=IRBuilder() begin
1327+
for use in collect(uses(intr))
1328+
call = user(use)
1329+
@assert call isa LLVM.CallInst
1330+
bytes, align = convert.(Int, operands(call)[1:2])
1331+
f = LLVM.parent(LLVM.parent(call))
1332+
1333+
# materialize the slot at the top of the entry block so that it is a static
1334+
# alloca (promotable, and allocated once rather than per loop iteration).
1335+
position!(builder, first(instructions(first(blocks(f)))))
1336+
slot = alloca!(builder, alloca_slot_type(bytes, align), "alloca")
1337+
alignment!(slot, align)
1338+
1339+
# `alloca!` placed the slot in the datalayout's alloca address space; cast it to
1340+
# the intrinsic's return type, i.e. the address space requested by the caller
1341+
# (emitting an addrspacecast when it differs from the alloca address space).
1342+
ptr = pointercast!(builder, slot, value_type(call))
1343+
1344+
replace_uses!(call, ptr)
1345+
erase!(call)
1346+
end
1347+
end
1348+
1349+
@assert isempty(uses(intr))
1350+
erase!(intr)
1351+
1352+
return true
1353+
end
1354+
12191355
# convert kernel state argument from pass-by-value to pass-by-reference
12201356
#
12211357
# the kernel state argument is always passed by value to avoid codegen issues with byval.

test/gcn.jl

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -475,5 +475,46 @@ end
475475
GCN.code_native(devnull, mod.kernel, Tuple{Float32,Ptr{Float32}})
476476
end
477477

478+
@testset "stack allocation intrinsic" begin
479+
mod = @eval module $(gensym())
480+
import ..GPUCompiler
481+
482+
function scratch(x)
483+
p = GPUCompiler.alloca(Float32, Val(8), Val(5))
484+
@inbounds unsafe_store!(p, x, 1)
485+
@inbounds unsafe_store!(p, x, 8)
486+
return @inbounds unsafe_load(p, 1) + unsafe_load(p, 8)
487+
end
488+
489+
# zero-element scratch yields a (null) pointer without emitting an alloca
490+
empty_scratch() = GPUCompiler.alloca(Float32, Val(0), Val(5)) === reinterpret(Core.LLVMPtr{Float32,5}, C_NULL)
491+
end
492+
493+
# AMDGPU uses alloca address space 5, which is exactly what the scratch requests, so the
494+
# materialized slot lives in AS 5 and no `addrspacecast` is needed.
495+
@test @filecheck begin
496+
@check_label "define float @{{(julia|j)_scratch_[0-9]+}}"
497+
@check "alloca [8 x i32], align 4, addrspace(5)"
498+
@check_not "addrspacecast"
499+
@check_not "julia.gpu.alloca"
500+
GCN.code_llvm(mod.scratch, Tuple{Float32}; optimize=false, dump_module=true)
501+
end
502+
503+
# once optimized the slot is promoted away entirely (result is x + x).
504+
@test @filecheck begin
505+
@check_label "define float @{{(julia|j)_scratch_[0-9]+}}"
506+
@check_not "julia.gpu.alloca"
507+
GCN.code_llvm(mod.scratch, Tuple{Float32})
508+
end
509+
510+
# a zero-byte allocation lowers to a null pointer rather than a degenerate alloca.
511+
@test @filecheck begin
512+
@check_label "define {{.*}}@{{(julia|j)_empty_scratch_[0-9]+}}"
513+
@check_not "alloca"
514+
@check_not "julia.gpu.alloca"
515+
GCN.code_llvm(mod.empty_scratch, Tuple{})
516+
end
517+
end
518+
478519
end
479520
end # :AMDGPU in LLVM.backends()

test/native.jl

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -800,3 +800,45 @@ end
800800
@test !occursin("deferred_codegen", ir)
801801
@test occursin("call void @julia_kernel", ir)
802802
end
803+
804+
@testset "stack allocation intrinsic" begin
805+
mod = @eval module $(gensym())
806+
import ..GPUCompiler
807+
808+
function scratch(x)
809+
p = GPUCompiler.alloca(Float32, Val(8), Val(0))
810+
@inbounds unsafe_store!(p, x, 1)
811+
@inbounds unsafe_store!(p, x, 8)
812+
return @inbounds unsafe_load(p, 1) + unsafe_load(p, 8)
813+
end
814+
815+
# zero-element scratch yields a (null) pointer without emitting an alloca
816+
empty_scratch() = GPUCompiler.alloca(Float32, Val(0), Val(0)) === reinterpret(Core.LLVMPtr{Float32,0}, C_NULL)
817+
end
818+
819+
# the intrinsic is materialized as a single entry-block alloca whose element type is
820+
# sized to the alignment (32 bytes of Float32 scratch → `[8 x i32], align 4`), and no
821+
# `julia.gpu.alloca` call/declaration survives lowering.
822+
@test @filecheck begin
823+
@check_label "define float @{{(julia|j)_scratch_[0-9]+}}"
824+
@check "alloca [8 x i32], align 4"
825+
@check_not "julia.gpu.alloca"
826+
Native.code_llvm(mod.scratch, Tuple{Float32}; optimize=false, dump_module=true)
827+
end
828+
829+
# once optimized the slot is promoted away entirely (result is x + x).
830+
@test @filecheck begin
831+
@check_label "define float @{{(julia|j)_scratch_[0-9]+}}"
832+
@check_not "alloca"
833+
@check_not "julia.gpu.alloca"
834+
Native.code_llvm(mod.scratch, Tuple{Float32})
835+
end
836+
837+
# a zero-byte allocation lowers to a null pointer rather than a degenerate alloca.
838+
@test @filecheck begin
839+
@check_label "define {{.*}}@{{(julia|j)_empty_scratch_[0-9]+}}"
840+
@check_not "alloca"
841+
@check_not "julia.gpu.alloca"
842+
Native.code_llvm(mod.empty_scratch, Tuple{})
843+
end
844+
end

0 commit comments

Comments
 (0)