Skip to content

Manually adding norecurse can significantly improve the performance on an AMD MI210 #1065

Closed

Description

@ranocha

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

  1. ranocha commented on Oct 29, 2026

    @ranocha
    MemberAuthor

    Thank you very much for working on this so quickly, @maleadt!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions