Repository navigation
Manually adding norecurse can significantly improve the performance on an AMD MI210 #1065
Description
While optimizing the performance of some GPU code for Trixi.jl using KernelAbstractions.jl, Claude Opus 5.5 helped me to discover that adding norecurse can significantly improve the performance of some kernels. I asked it to create an MWE, which you can find below together with some output.
Something like this was not required for CUDA.jl or Metal.jl (at least for the GPUs I had access to).
This appears to be related to #262.
CC @vchuravy
Script file mwe_amdgpu_norecurse.jl:
# MWE for an upstream issue (notes/upstream_norecurse_issue.md): on AMD GPUs, a kernel that merely contains a call to a
# `@noinline` device function is compiled with `.amdhsa_uses_dynamic_stack 1`, because GPUCompiler never infers `norecurse`
# for the callee. The call below is never executed, yet the kernel gets slower.
#
# Self-contained: needs AMDGPU.jl (which brings GPUCompiler.jl and LLVM.jl) and an AMD GPU. Usage:
# julia --project=<env with AMDGPU, GPUCompiler, LLVM> mwe_amdgpu_norecurse.jl [none|infer|entry|force]
# none : stock compilation
# infer : run LLVM's bottom-up `function-attrs` pass (PostOrderFunctionAttrsPass, part of LLVM's default -O pipelines) on
# the optimized module, via the `GPUCompiler.optimize_module!` hook (not defined for GCN by GPUCompiler or AMDGPU).
# LLVM < 18 infers `norecurse` only if every callee, intrinsics included, already has it (LLVM #76372 accepts
# `nocallback` declarations from LLVM 18 on).
# entry : mark the kernel entry point `norecurse` (cf. GPUCompiler #262), then run LLVM's top-down `rpo-function-attrs`
# pass, which marks internal functions called only from `norecurse` functions
# force : add `norecurse` to every function defined in the module (the workaround used in our benchmarks)
# Run each mode in a fresh process (kernels are cached per process).
using AMDGPU, GPUCompiler, LLVM
using Printf, Statistics
const MODE = get(ARGS, 1, "none")
MODE in ("none", "infer", "entry", "force") || error("mode: none | infer | entry | force")
function run_pipeline!(job, mod, pipeline)
PB = isdefined(LLVM, :NewPMPassBuilder) ? LLVM.NewPMPassBuilder : LLVM.PassBuilder # LLVM.jl 9 / 10
@dispose tm=GPUCompiler.llvm_machine(job.config.target) pb=PB() begin
LLVM.add!(pb, pipeline)
LLVM.run!(pb, mod, tm)
end
end
if MODE != "none"
function GPUCompiler.optimize_module!(@nospecialize(job::GPUCompiler.CompilerJob{GPUCompiler.GCNCompilerTarget}),
mod::LLVM.Module)
if MODE == "infer"
run_pipeline!(job, mod, "cgscc(function-attrs)")
elseif MODE == "entry"
# GPUCompiler's `finish_module!` has already given the entry point the kernel calling convention
for f in LLVM.functions(mod)
if LLVM.API.LLVMGetFunctionCallConv(f) == UInt32(LLVM.API.LLVMAMDGPUKERNELCallConv)
push!(LLVM.function_attributes(f), LLVM.EnumAttribute("norecurse", 0))
end
end
run_pipeline!(job, mod, "rpo-function-attrs")
else
for f in LLVM.functions(mod)
isempty(LLVM.blocks(f)) && continue # skip declarations
push!(LLVM.function_attributes(f), LLVM.EnumAttribute("norecurse", 0))
end
end
return
end
end
@inline function cold_body(x)
y = x
for _ in 1:8
y = fma(y, y, x) / (abs(y) + one(x))
end
return y
end
@noinline cold(x) = cold_body(x)
@inline function hot(x)
s = x
for _ in 1:16
s = fma(s, oftype(x, 0.999), oftype(x, 0.001))
end
return s
end
# identical kernels except for the rare branch: inlined vs. a call to the `@noinline` function
function kernel_inline!(y, x)
i = (workgroupIdx().x - 1) * workgroupDim().x + workitemIdx().x
if i <= length(x)
@inbounds xi = x[i]
s = hot(xi)
if !(xi < floatmax(xi)) # never true for the finite inputs below
s = cold_body(xi)
end
@inbounds y[i] = s
end
return
end
function kernel_call!(y, x)
i = (workgroupIdx().x - 1) * workgroupDim().x + workitemIdx().x
if i <= length(x)
@inbounds xi = x[i]
s = hot(xi)
if !(xi < floatmax(xi)) # never true for the finite inputs below
s = cold(xi)
end
@inbounds y[i] = s
end
return
end
function descriptor(kernel, tt)
io = IOBuffer()
# `dump_module = true` is essential: otherwise GPUCompiler empties all non-entry functions (`only_entry`), the callee
# becomes a declaration, and LLVM reports a dynamic stack for any kernel with a call
AMDGPU.code_gcn(io, kernel, tt; kernel = true, dump_module = true)
asm = String(take!(io))
keys = (".amdhsa_uses_dynamic_stack", ".amdhsa_private_segment_fixed_size", "SCRATCH_EN", "ScratchSize")
return [strip(l) for l in split(asm, '\n') if any(k -> occursin(k, l), keys)]
end
function callee_attributes(kernel, tt)
io = IOBuffer()
AMDGPU.code_llvm(io, kernel, tt; kernel = true, dump_module = true)
ll = String(take!(io))
lines = split(ll, '\n')
defs = [strip(l) for l in lines if startswith(l, "define") && occursin("julia_cold", l)]
decls = [strip(l) for l in lines if startswith(l, "declare")]
attrs = [strip(l) for l in lines if startswith(l, "attributes #")]
return defs, decls, attrs
end
function main(; T = Float64, n = 2^24, groupsize = 256, reps = 200)
println("AMDGPU.jl ", pkgversion(AMDGPU), ", GPUCompiler.jl ", pkgversion(GPUCompiler), ", LLVM.jl ", pkgversion(LLVM),
", Julia ", VERSION, ", libLLVM ", Base.libllvm_version)
println("device: ", AMDGPU.device(), "; mode: ", MODE, "; T = ", T, ", n = ", n)
x = ROCArray(T.(range(0.5, 2.0; length = n)))
y_inline = similar(x)
y_call = similar(x)
tt = Tuple{typeof(AMDGPU.rocconvert(y_call)), typeof(AMDGPU.rocconvert(x))}
gridsize = cld(n, groupsize)
for (name, kernel) in (("kernel_inline!", kernel_inline!), ("kernel_call!", kernel_call!))
println("\n", name, ": kernel descriptor")
foreach(l -> println(" ", l), descriptor(kernel, tt))
end
defs, decls, attrs = callee_attributes(kernel_call!, tt)
println("\nkernel_call!: definition of the callee, declarations and attribute groups (optimized IR)")
foreach(l -> println(" ", l), defs)
foreach(l -> println(" ", l), decls)
foreach(l -> println(" ", l), attrs)
k_inline = @roc launch=false kernel_inline!(y_inline, x)
k_call = @roc launch=false kernel_call!(y_call, x)
k_inline(y_inline, x; groupsize, gridsize)
k_call(y_call, x; groupsize, gridsize)
AMDGPU.synchronize()
println("\nresults equal: ", Array(y_inline) == Array(y_call))
t_inline = Float64[]
t_call = Float64[]
for _ in 1:reps # interleaved
push!(t_inline, AMDGPU.@elapsed k_inline(y_inline, x; groupsize, gridsize))
push!(t_call, AMDGPU.@elapsed k_call(y_call, x; groupsize, gridsize))
end
@printf("median time (%d launches each): kernel_inline! %.4f ms, kernel_call! %.4f ms, ratio %.3f\n",
reps, 1e3 * median(t_inline), 1e3 * median(t_call), median(t_call) / median(t_inline))
return nothing
end
main()Outputs from running the MWE on Julia 1.10 on an MI210:
(amdgpumwe) pkg> st
Status `/tmp/amdgpumwe/Project.toml`
[21141c5a] AMDGPU v2.9.2
[61eb1bfa] GPUCompiler v2.17.0
[929cbde3] LLVM v10.4.0
[de0858da] Printf
[10745b16] Statistics v1.10.0
$ julia --project=/tmp/amdgpumwe mwe_amdgpu_norecurse.jl none
AMDGPU.jl 2.9.2, GPUCompiler.jl 2.17.0, LLVM.jl 10.4.0, Julia 1.10.12, libLLVM 15.0.7
device: HIPDevice(id=1, name=AMD Instinct MI210, gcn_arch=gfx90a:sramecc+:xnack-); mode: none; T = Float64, n = 16777216
kernel_inline!: kernel descriptor
.amdhsa_private_segment_fixed_size 0
.amdhsa_uses_dynamic_stack 0
; ScratchSize: 0
; COMPUTE_PGM_RSRC2:SCRATCH_EN: 0
kernel_call!: kernel descriptor
; ScratchSize: 0
.amdhsa_private_segment_fixed_size 0
.amdhsa_uses_dynamic_stack 1
; ScratchSize: 0
; COMPUTE_PGM_RSRC2:SCRATCH_EN: 1
kernel_call!: definition of the callee, declarations and attribute groups (optimized IR)
define internal fastcc double @julia_cold_1180(double %0) unnamed_addr #0 {
declare align 4 i8 addrspace(4)* @llvm.amdgcn.implicitarg.ptr() #1
declare i32 @llvm.amdgcn.workgroup.id.x() #1
declare i32 @llvm.amdgcn.workitem.id.x() #1
declare double @llvm.fabs.f64(double) #2
declare double @llvm.fma.f64(double, double, double) #2
attributes #0 = { noinline }
attributes #1 = { mustprogress nofree nosync nounwind readnone speculatable willreturn }
attributes #2 = { alwaysinline mustprogress nocallback nofree nosync nounwind readnone speculatable willreturn }
attributes #3 = { "amdgpu-implicitarg-num-bytes"="256" "amdgpu-unsafe-fp-atomics"="true" "target-cpu"="gfx90a" "target-features"="+sramecc,-wavefrontsize32,+wavefrontsize64" }
results equal: true
median time (200 launches each): kernel_inline! 0.1977 ms, kernel_call! 0.4013 ms, ratio 2.030
$ julia --project=/tmp/amdgpumwe mwe_amdgpu_norecurse.jl infer
AMDGPU.jl 2.9.2, GPUCompiler.jl 2.17.0, LLVM.jl 10.4.0, Julia 1.10.12, libLLVM 15.0.7
device: HIPDevice(id=1, name=AMD Instinct MI210, gcn_arch=gfx90a:sramecc+:xnack-); mode: infer; T = Float64, n = 16777216
kernel_inline!: kernel descriptor
.amdhsa_private_segment_fixed_size 0
.amdhsa_uses_dynamic_stack 0
; ScratchSize: 0
; COMPUTE_PGM_RSRC2:SCRATCH_EN: 0
kernel_call!: kernel descriptor
; ScratchSize: 0
.amdhsa_private_segment_fixed_size 0
.amdhsa_uses_dynamic_stack 1
; ScratchSize: 0
; COMPUTE_PGM_RSRC2:SCRATCH_EN: 1
kernel_call!: definition of the callee, declarations and attribute groups (optimized IR)
define internal fastcc double @julia_cold_1218(double %0) unnamed_addr #0 {
declare align 4 i8 addrspace(4)* @llvm.amdgcn.implicitarg.ptr() #1
declare i32 @llvm.amdgcn.workgroup.id.x() #1
declare i32 @llvm.amdgcn.workitem.id.x() #1
declare double @llvm.fabs.f64(double) #2
declare double @llvm.fma.f64(double, double, double) #2
attributes #0 = { mustprogress nofree noinline nosync nounwind readnone willreturn }
attributes #1 = { mustprogress nofree nosync nounwind readnone speculatable willreturn }
attributes #2 = { alwaysinline mustprogress nocallback nofree nosync nounwind readnone speculatable willreturn }
attributes #3 = { mustprogress nofree nosync nounwind willreturn "amdgpu-implicitarg-num-bytes"="256" "amdgpu-unsafe-fp-atomics"="true" "target-cpu"="gfx90a" "target-features"="+sramecc,-wavefrontsize32,+wavefrontsize64" }
results equal: true
median time (200 launches each): kernel_inline! 0.1976 ms, kernel_call! 0.4010 ms, ratio 2.029
$ julia --project=/tmp/amdgpumwe mwe_amdgpu_norecurse.jl entry
AMDGPU.jl 2.9.2, GPUCompiler.jl 2.17.0, LLVM.jl 10.4.0, Julia 1.10.12, libLLVM 15.0.7
device: HIPDevice(id=1, name=AMD Instinct MI210, gcn_arch=gfx90a:sramecc+:xnack-); mode: entry; T = Float64, n = 16777216
kernel_inline!: kernel descriptor
.amdhsa_private_segment_fixed_size 0
.amdhsa_uses_dynamic_stack 0
; ScratchSize: 0
; COMPUTE_PGM_RSRC2:SCRATCH_EN: 0
kernel_call!: kernel descriptor
; ScratchSize: 0
.amdhsa_private_segment_fixed_size 0
.amdhsa_uses_dynamic_stack 0
; ScratchSize: 0
; COMPUTE_PGM_RSRC2:SCRATCH_EN: 0
kernel_call!: definition of the callee, declarations and attribute groups (optimized IR)
define internal fastcc double @julia_cold_1222(double %0) unnamed_addr #0 {
declare align 4 i8 addrspace(4)* @llvm.amdgcn.implicitarg.ptr() #1
declare i32 @llvm.amdgcn.workgroup.id.x() #1
declare i32 @llvm.amdgcn.workitem.id.x() #1
declare double @llvm.fabs.f64(double) #2
declare double @llvm.fma.f64(double, double, double) #2
attributes #0 = { noinline norecurse }
attributes #1 = { mustprogress nofree nosync nounwind readnone speculatable willreturn }
attributes #2 = { alwaysinline mustprogress nocallback nofree nosync nounwind readnone speculatable willreturn }
attributes #3 = { norecurse "amdgpu-implicitarg-num-bytes"="256" "amdgpu-unsafe-fp-atomics"="true" "target-cpu"="gfx90a" "target-features"="+sramecc,-wavefrontsize32,+wavefrontsize64" }
results equal: true
median time (200 launches each): kernel_inline! 0.1978 ms, kernel_call! 0.1981 ms, ratio 1.002
$ julia --project=/tmp/amdgpumwe mwe_amdgpu_norecurse.jl force
AMDGPU.jl 2.9.2, GPUCompiler.jl 2.17.0, LLVM.jl 10.4.0, Julia 1.10.12, libLLVM 15.0.7
device: HIPDevice(id=1, name=AMD Instinct MI210, gcn_arch=gfx90a:sramecc+:xnack-); mode: force; T = Float64, n = 16777216
kernel_inline!: kernel descriptor
.amdhsa_private_segment_fixed_size 0
.amdhsa_uses_dynamic_stack 0
; ScratchSize: 0
; COMPUTE_PGM_RSRC2:SCRATCH_EN: 0
kernel_call!: kernel descriptor
; ScratchSize: 0
.amdhsa_private_segment_fixed_size 0
.amdhsa_uses_dynamic_stack 0
; ScratchSize: 0
; COMPUTE_PGM_RSRC2:SCRATCH_EN: 0
kernel_call!: definition of the callee, declarations and attribute groups (optimized IR)
define internal fastcc double @julia_cold_1203(double %0) unnamed_addr #0 {
declare align 4 i8 addrspace(4)* @llvm.amdgcn.implicitarg.ptr() #1
declare i32 @llvm.amdgcn.workgroup.id.x() #1
declare i32 @llvm.amdgcn.workitem.id.x() #1
declare double @llvm.fabs.f64(double) #2
declare double @llvm.fma.f64(double, double, double) #2
attributes #0 = { noinline norecurse }
attributes #1 = { mustprogress nofree nosync nounwind readnone speculatable willreturn }
attributes #2 = { alwaysinline mustprogress nocallback nofree nosync nounwind readnone speculatable willreturn }
attributes #3 = { norecurse "amdgpu-implicitarg-num-bytes"="256" "amdgpu-unsafe-fp-atomics"="true" "target-cpu"="gfx90a" "target-features"="+sramecc,-wavefrontsize32,+wavefrontsize64" }
results equal: true
median time (200 launches each): kernel_inline! 0.1978 ms, kernel_call! 0.1981 ms, ratio 1.002
Issue text Claude suggested for context:
A kernel that contains a call to a @noinline device function is compiled with .amdhsa_uses_dynamic_stack 1 and scratch enabled, even if the call is never executed and the callee calls nothing. On an MI210 this makes such kernels much slower than the same kernel with the callee inlined.
Cause. LLVM's AMDGPU resource-usage analysis treats a call to any function without norecurse as possibly recursive (AMDGPUResourceUsageAnalysis.cpp: if (!Callee || !Callee->doesNotRecurse()) Info.HasRecursion = true;), and DynamicCallStack = HasDynSizedStack | HasRecursion. Julia emits @noinline functions with only noinline. LLVM's default pipelines infer norecurse in PostOrderFunctionAttrsPass (function-attrs), but GPUCompiler's pipeline does not run it (only ForceFunctionAttrsPass and InferFunctionAttrsPass), and the AMDGPU attributor that AMDGPU.jl runs since #1056 does not infer norecurse. On NVPTX this does not matter, because ptxas sizes the stack from the call graph itself.
MWE (only AMDGPU.jl, GPUCompiler.jl, LLVM.jl): See above
Possible fix. Adding norecurse to the callee by hand removes the dynamic stack and the slowdown ([mode force output]). Inferring it needs care: LLVM's bottom-up function-attrs does not infer norecurse on LLVM 15 (Julia 1.10) because the callee calls intrinsics without norecurse ([mode infer output]; LLVM ≥ 18 also accepts nocallback callees). Marking kernel entry points norecurse (#262) and running the top-down rpo-function-attrs [mode entry output] would cover internal callees on all LLVM versions. Where to do it (GPUCompiler's pipeline for all targets, or GCN only) is for the maintainers.
Reflection caveat. code_native / @device_code_gcn without dump_module=true empties the callees (only_entry), so the printed kernel always shows .amdhsa_uses_dynamic_stack 1 for a kernel with a call, whatever the attributes; use dump_module=true to see the real descriptor.
Real-world impact. In Trixi.jl's flux-differencing kernels (3D compressible Euler, P4estMesh, polydeg = 3), moving a rarely taken branch out of line with @noinline made the kernels 1.3–3.8× slower on an MI210 (AMDGPU.jl 2.8.0); with norecurse added to the callees (via a finish_ir! override) they became 17–20% faster than the inlined version, and the ISA differed only in the dynamic-stack / scratch flags.
Activity
ranocha commentedon Oct 29, 2026
Thank you very much for working on this so quickly, @maleadt!