diff --git a/.buildkite/downstream.yml b/.buildkite/downstream.yml index 084a8ee08..f03faebc3 100644 --- a/.buildkite/downstream.yml +++ b/.buildkite/downstream.yml @@ -4,6 +4,14 @@ # `src/` and `ext/`, `Project.toml` and the CI configuration, except the other back-ends' # target files. The `if:` conditions implement the commit message tags documented in the # Development section of the README; keep both in sync when adding a step. +# +# Every package also gets a "(ports)" step, +# which runs its tests with the ports of Julia's passes (the `julia_passes = "ports"` +# preference, with JuliaIR.jl set up by `.ci/juliair.jl` in an environment of its own, +# as the default one persists on some agents) and fails unless JuliaIR.jl's +# ledger shows that they ran. `[only ports]` selects only the ports steps and +# `[skip ports]` skips them; a package's own tags (e.g. `[only cuda]`) select both of its +# steps. Steps are skipped on draft pull requests unless a tag selects them. steps: - label: "CUDA.jl" plugins: @@ -34,6 +42,7 @@ steps: - "ext/**" - "Project.toml" - ".buildkite/**" + - ".ci/**" exclude: - "src/{gcn,spirv,metal,bpf,native}.jl" if: | @@ -44,6 +53,39 @@ steps: soft_fail: - exit_status: 3 + - label: "CUDA.jl (ports)" + plugins: + - JuliaCI/julia#v1: + version: "1.13" + command: | + unset LD_LIBRARY_PATH + echo "--- :julia: Instantiating project" + # an environment of its own, so that the preference doesn't reach the other steps + export JULIA_PROJECT="$$(mktemp -d)" + julia .ci/juliair.jl downstream CUDA + + export JULIAIR_LEDGER="$$PWD/juliair-ledger.tsv" + rm -f "$$JULIAIR_LEDGER" + julia -e 'println("+++ :julia: Running tests") + using Pkg + Pkg.test("CUDA")' + + echo "+++ :julia: Checking that the ports ran" + julia .ci/juliair.jl check-ledger "$$JULIAIR_LEDGER" + agents: + queue: "cuda" + env: + JULIA_CPU_THREADS: "3" + if_changed: + include: *sources + exclude: + - "src/{gcn,spirv,metal,bpf,native}.jl" + if: | + build.message !~ /\[skip [^\]]*(tests|cuda|ptx|ports)/ && + (build.message =~ /\[only [^\]]*(cuda|ptx|ports)/ || + build.message !~ /\[only / && !build.pull_request.draft) + timeout_in_minutes: 120 + - label: "oneAPI.jl" plugins: - JuliaCI/julia#v1: @@ -77,6 +119,39 @@ steps: soft_fail: - exit_status: 3 + - label: "oneAPI.jl (ports)" + plugins: + - JuliaCI/julia#v1: + version: "1.13" + command: | + echo "--- :julia: Instantiating project" + # an environment of its own, so that the preference doesn't reach the other steps + export JULIA_PROJECT="$$(mktemp -d)" + julia .ci/juliair.jl downstream oneAPI + julia -e 'using Pkg; + println("+++ :julia: Building support library") + include(joinpath(Pkg.devdir(), "oneAPI", "deps", "build_ci.jl"))' + + export JULIAIR_LEDGER="$$PWD/juliair-ledger.tsv" + rm -f "$$JULIAIR_LEDGER" + julia -e 'println("+++ :julia: Running tests") + using Pkg + Pkg.test("oneAPI")' + + echo "+++ :julia: Checking that the ports ran" + julia .ci/juliair.jl check-ledger "$$JULIAIR_LEDGER" + agents: + queue: "oneapi" + if_changed: + include: *sources + exclude: + - "src/{ptx,gcn,metal,bpf,native}.jl" + if: | + build.message !~ /\[skip [^\]]*(tests|oneapi|spirv|ports)/ && + (build.message =~ /\[only [^\]]*(oneapi|spirv|ports)/ || + build.message !~ /\[only / && !build.pull_request.draft) + timeout_in_minutes: 60 + - label: "AMDGPU.jl" plugins: - JuliaCI/julia#v1: @@ -106,6 +181,36 @@ steps: soft_fail: - exit_status: 3 + - label: "AMDGPU.jl (ports)" + plugins: + - JuliaCI/julia#v1: + version: "1.13" + command: | + echo "--- :julia: Instantiating project" + # an environment of its own, so that the preference doesn't reach the other steps + export JULIA_PROJECT="$$(mktemp -d)" + julia .ci/juliair.jl downstream AMDGPU + + export JULIAIR_LEDGER="$$PWD/juliair-ledger.tsv" + rm -f "$$JULIAIR_LEDGER" + julia -e 'println("+++ :julia: Running tests") + using Pkg + Pkg.test("AMDGPU")' + + echo "+++ :julia: Checking that the ports ran" + julia .ci/juliair.jl check-ledger "$$JULIAIR_LEDGER" + agents: + queue: "rocm" + if_changed: + include: *sources + exclude: + - "src/{ptx,spirv,metal,bpf,native}.jl" + if: | + build.message !~ /\[skip [^\]]*(tests|amdgpu|gcn|ports)/ && + (build.message =~ /\[only [^\]]*(amdgpu|gcn|ports)/ || + build.message !~ /\[only / && !build.pull_request.draft) + timeout_in_minutes: 60 + - label: "Metal.jl" plugins: - JuliaCI/julia#v1: @@ -146,6 +251,36 @@ steps: soft_fail: - exit_status: 3 + - label: "Metal.jl (ports)" + plugins: + - JuliaCI/julia#v1: + version: "1.13" + command: | + echo "--- :julia: Instantiating project" + # an environment of its own, so that the preference doesn't reach the other steps + export JULIA_PROJECT="$$(mktemp -d)" + JULIA_PKG_PRECOMPILE_AUTO=0 julia .ci/juliair.jl downstream Metal --workspace + + export JULIAIR_LEDGER="$$PWD/juliair-ledger.tsv" + rm -f "$$JULIAIR_LEDGER" + julia -e 'println("+++ :julia: Running tests") + using Pkg + Pkg.test("Metal")' + + echo "+++ :julia: Checking that the ports ran" + julia .ci/juliair.jl check-ledger "$$JULIAIR_LEDGER" + agents: + queue: "metal" + if_changed: + include: *sources + exclude: + - "src/{ptx,gcn,spirv,bpf,native}.jl" + if: | + build.message !~ /\[skip [^\]]*(tests|metal|ports)/ && + (build.message =~ /\[only [^\]]*(metal|ports)/ || + build.message !~ /\[only / && !build.pull_request.draft) + timeout_in_minutes: 120 + - label: "OpenCL.jl" plugins: - JuliaCI/julia#v1: @@ -176,5 +311,35 @@ steps: soft_fail: - exit_status: 3 + - label: "OpenCL.jl (ports)" + plugins: + - JuliaCI/julia#v1: + version: "1.13" + command: | + echo "--- :julia: Instantiating project" + # an environment of its own, so that the preference doesn't reach the other steps + export JULIA_PROJECT="$$(mktemp -d)" + julia .ci/juliair.jl downstream OpenCL SPIRVIntrinsics + + export JULIAIR_LEDGER="$$PWD/juliair-ledger.tsv" + rm -f "$$JULIAIR_LEDGER" + julia -e 'println("+++ :julia: Running tests") + using Pkg + Pkg.test("OpenCL")' + + echo "+++ :julia: Checking that the ports ran" + julia .ci/juliair.jl check-ledger "$$JULIAIR_LEDGER" + agents: + queue: "cuda" + if_changed: + include: *sources + exclude: + - "src/{ptx,gcn,metal,bpf,native}.jl" + if: | + build.message !~ /\[skip [^\]]*(tests|opencl|spirv|ports)/ && + (build.message =~ /\[only [^\]]*(opencl|spirv|ports)/ || + build.message !~ /\[only / && !build.pull_request.draft) + timeout_in_minutes: 120 + env: JULIA_PKG_SERVER_REGISTRY_PREFERENCE: "eager" # see freshly-registered versions without cloning the registry diff --git a/.ci/juliair.jl b/.ci/juliair.jl new file mode 100644 index 000000000..f29132e78 --- /dev/null +++ b/.ci/juliair.jl @@ -0,0 +1,127 @@ +# Set up CI environments for the selected Julia pass provider. +# +# julia .ci/juliair.jl test (cpp|ports) +# +# Prepares GPUCompiler's test environment (`test/`), for `julia --project=test +# test/runtests.jl`, with the `julia_passes` preference set to the given provider. +# +# julia .ci/juliair.jl downstream PACKAGE [PACKAGES...] [--workspace] +# +# Develops GPUCompiler and the downstream PACKAGE (and further PACKAGES, e.g. packages of +# PACKAGE's repository) into the active environment, for `Pkg.test(PACKAGE)` with the +# ports: JuliaIR.jl is added too, and to PACKAGE's test environment, where GPUCompiler +# loads it. The preference goes into the active environment (in CI, a temporary one), +# which `Pkg.test` passes on to the test environment; GPUCompiler is added to PACKAGE's +# test dependencies so that it applies there. `--workspace` also instantiates PACKAGE's +# workspace against GPUCompiler (Metal.jl's tests run examples in its workspace +# environments). +# +# julia .ci/juliair.jl check-ledger FILE +# +# Fails unless FILE, the JuliaIR.jl ledger (`JULIAIR_LEDGER`) of a run with the ports, +# shows that the core ports ran. + +using Pkg, TOML + +const ROOT = dirname(@__DIR__) +const GPUCOMPILER_UUID = "61eb1bfa-7361-4325-ad38-22787b887f55" +const JULIAIR_UUID = "3913dba9-7ba9-44aa-bf76-1cfa3b3073b1" + +function write_preferences(dir, mode) + # an environment that doesn't exist yet (Pkg creates it on the first change) + mkpath(dir) + file = joinpath(dir, "LocalPreferences.toml") + open(io -> TOML.print(io, Dict("GPUCompiler" => Dict("julia_passes" => mode))), file, "w") + println("$file:") + print(read(file, String)) +end + +function test(mode) + mode in ("cpp", "ports") || error("Unknown provider $mode; expected cpp or ports") + write_preferences(joinpath(ROOT, "test"), mode) + Pkg.activate(joinpath(ROOT, "test")) + # Julia 1.10 and 1.11 have no workspaces, so the test environment needs GPUCompiler + VERSION < v"1.12" && Pkg.develop(path=ROOT) + Pkg.instantiate() + Pkg.status() +end + +function downstream(packages, workspace) + package = first(packages) + env = dirname(Base.active_project()) + write_preferences(env, "ports") + + Pkg.develop(path=ROOT) + Pkg.add(PackageSpec(name="JuliaIR", version="0.1")) + Pkg.develop([PackageSpec(name=name) for name in packages]) + + checkout = joinpath(Pkg.devdir(), package) + if workspace + # before adding JuliaIR.jl to the test environment, which is part of the workspace + # and could then not be resolved; the examples that need the workspace run in the + # test process, which has loaded GPUCompiler and JuliaIR.jl already + Pkg.activate(checkout) + Pkg.develop(path=ROOT) + Pkg.instantiate(workspace=true) + Pkg.activate(env) + end + test_project = joinpath(checkout, "test", "Project.toml") + isfile(test_project) || error("$package has no test/Project.toml") + add = Dict("GPUCompiler" => GPUCOMPILER_UUID, "JuliaIR" => JULIAIR_UUID) + # by editing the text, as rewriting it with TOML.print reorders the file + deps = get(TOML.parsefile(test_project), "deps", Dict()) + lines = [k * " = \"" * v * "\"" for (k, v) in add if !haskey(deps, k)] + if !isempty(lines) + text = read(test_project, String) + occursin(r"^\[deps\]$"m, text) || (text = "[deps]\n\n" * text) + text = replace(text, r"^\[deps\]$"m => join(["[deps]"; lines], "\n"); count=1) + write(test_project, text) + end + + Pkg.status() +end + +# the ports that run for every kernel on every target and Julia version, and JuliaLICM, +# which runs for every loop (that test suites have) +const CORE_PORTS = ["jl-alloc-opt", "jl-late-lower-gc-frame", "jl-propagate-addrspaces", + "jl-remove-ni", "jl-demote-float16", "jl-remove-addrspaces", "jl-licm"] + +function check_ledger(file) + isfile(file) || error("No JuliaIR.jl ledger at $file: the ports did not run") + counts = Dict{String,Int}() + pids = Set{String}() + for (i, line) in enumerate(eachline(file)) + # PID, "pass", the pass, the Julia dialect and the LLVM version + fields = split(line, '\t') + if !(length(fields) == 5 && all(isdigit, fields[1]) && fields[2] == "pass" && + startswith(fields[3], "jl-") && startswith(fields[4], "julia=") && + startswith(fields[5], "llvm=")) + error("Malformed line $i in the ledger at $file: $(repr(line))") + end + counts[fields[3]] = get(counts, fields[3], 0) + 1 + push!(pids, fields[1]) + end + for (name, count) in sort!(collect(counts)) + println(rpad(name, 32), count) + end + println("in $(length(pids)) processes") + missing_ports = setdiff(CORE_PORTS, keys(counts)) + isempty(missing_ports) || + error("The ports $(join(missing_ports, ", ")) did not run; is GPUCompiler using the C++ passes?") +end + +function main(args) + isempty(args) && error("Usage: julia .ci/juliair.jl (test MODE | downstream PACKAGE... [--workspace] | check-ledger FILE)") + command = args[1] + if command == "test" + test(args[2]) + elseif command == "downstream" + downstream(filter(!=("--workspace"), args[2:end]), "--workspace" in args) + elseif command == "check-ledger" + check_ledger(args[2]) + else + error("Unknown command $command") + end +end + +main(ARGS) diff --git a/.github/workflows/Test.yml b/.github/workflows/Test.yml index ca1ac8e8c..fdb1191af 100644 --- a/.github/workflows/Test.yml +++ b/.github/workflows/Test.yml @@ -13,7 +13,7 @@ concurrency: jobs: release_test: - name: Julia ${{ matrix.version }} ${{ matrix.llvm_args }} - ${{ matrix.os }} + name: Julia ${{ matrix.version }} ${{ matrix.llvm_args }} - ${{ matrix.os }} - ${{ matrix.julia_passes }} runs-on: ${{ matrix.os }} timeout-minutes: 20 strategy: @@ -22,33 +22,43 @@ jobs: version: ['1.10', '1.11', '1.12', '1.13', 'nightly'] os: [ubuntu-24.04, ubuntu-24.04-arm, macOS-15, windows-2025] llvm_args: [''] + # the provider of Julia's passes (the `julia_passes` preference) + julia_passes: [cpp, ports] include: # starting with Julia 1.10, we can enable opaque pointers - # from Julia 1.12 on, this is the default. + # from Julia 1.12 on, this is the default. (the ports always use them.) - version: '1.10' os: 'ubuntu-24.04' llvm_args: '--opaque-pointers' + julia_passes: cpp - version: '1.10' os: 'ubuntu-24.04-arm' llvm_args: '--opaque-pointers' + julia_passes: cpp - version: '1.10' os: 'macOS-15' llvm_args: '--opaque-pointers' + julia_passes: cpp - version: '1.10' os: 'windows-2025' llvm_args: '--opaque-pointers' + julia_passes: cpp - version: '1.11' os: 'ubuntu-24.04' llvm_args: '--opaque-pointers' + julia_passes: cpp - version: '1.11' os: 'ubuntu-24.04-arm' llvm_args: '--opaque-pointers' + julia_passes: cpp - version: '1.11' os: 'macOS-15' llvm_args: '--opaque-pointers' + julia_passes: cpp - version: '1.11' os: 'windows-2025' llvm_args: '--opaque-pointers' + julia_passes: cpp steps: - uses: actions/checkout@v7 @@ -60,19 +70,20 @@ jobs: - name: Set-up cache uses: julia-actions/cache@v3 + # Run the suite in the environment with the selected pass-provider preference. + - name: Set up the test environment + run: julia --color=yes .ci/juliair.jl test ${{ matrix.julia_passes }} + - name: Run tests - uses: julia-actions/julia-runtest@v1 + run: julia --color=yes --project=test test/runtests.jl env: JULIA_LLVM_ARGS: ${{ matrix.llvm_args }} + JULIAIR_LEDGER: ${{ runner.temp }}/juliair-ledger.tsv - - name: Process coverage - # CoverageTools 1.4.1 uses a PkgLoadSpec field renamed in Julia 1.14. - if: matrix.version != 'nightly' - uses: julia-actions/julia-processcoverage@v1 - - name: Submit coverage - if: matrix.version != 'nightly' - uses: codecov/codecov-action@v7 - with: - token: ${{ secrets.CODECOV_TOKEN }} - fail_ci_if_error: false - files: lcov.info + # the suite also runs the ports explicitly, so check the preference too + - name: Check that the ports were the default + if: matrix.julia_passes == 'ports' + run: julia --project=test -e 'using GPUCompiler; GPUCompiler.JULIA_PASSES === :ports || error("julia_passes preference not applied")' + - name: Check that the ports ran + if: matrix.julia_passes == 'ports' + run: julia .ci/juliair.jl check-ledger '${{ runner.temp }}/juliair-ledger.tsv' diff --git a/Project.toml b/Project.toml index f50ee3299..4ce29828d 100644 --- a/Project.toml +++ b/Project.toml @@ -29,6 +29,7 @@ tree_sitter_spirv_jll = "f0e86581-c468-54df-a4de-3266e11a3c86" AMDGPU_LLVM_Backend_jll = "cc5c0156-bd05-5a77-8a68-bb0aafb29019" Cthulhu = "f68482b8-f384-11e8-15f7-abe071a5a75f" Highlights = "eafb193a-b7ab-5a9e-9068-77385905fa72" +JuliaIR = "3913dba9-7ba9-44aa-bf76-1cfa3b3073b1" LLVMDowngrader_jll = "f52de702-fb25-5922-94ba-81dd59b07444" NVPTX_LLVM_Backend_jll = "ef6e0fe3-e6ef-59c0-bde6-4989574699e0" SPIRV_LLVM_Backend_jll = "4376b9bf-cff8-51b6-bb48-39421dff0d0c" @@ -37,6 +38,7 @@ SPIRV_LLVM_Translator_jll = "4a5d46fc-d8cf-5151-a261-86b458210efb" [extensions] CthulhuExt = "Cthulhu" HighlightsExt = "Highlights" +JuliaIRExt = "JuliaIR" [compat] AMDGPU_LLVM_Backend_jll = "23.1.3" @@ -45,6 +47,7 @@ Cthulhu = "2, 3" ExprTools = "0.1" Highlights = "0.6" InteractiveUtils = "1" +JuliaIR = "0.1" LLVM = "10.4" LLVMDowngrader_jll = "0.11, 0.12" Libdl = "1" diff --git a/README.md b/README.md index fc8988a23..08731862a 100644 --- a/README.md +++ b/README.md @@ -51,3 +51,28 @@ last commit of a pull request: Back-ends are named `cuda`, `amdgpu`, `metal`, `oneapi` and `opencl` (all lowercase), or by their target: `ptx` for CUDA.jl, `gcn` for AMDGPU.jl, and `spirv` for both oneAPI.jl and OpenCL.jl. + + +## Julia's LLVM passes + +GPUCompiler's optimization pipeline includes several of the LLVM passes of Julia's code +generator, e.g., to lower GC intrinsics or to optimize allocations. These normally come from +Julia's C++ implementation, but can also be taken from [JuliaIR.jl](https://github.com/JuliaLLVM/JuliaIR.jl), +which ports them to Julia. This is selected per compilation with the `julia_passes` keyword +argument of `CompilerConfig` (`:cpp` or `:ports`), whose default comes from a preference: + +```julia +using Preferences, GPUCompiler +set_preferences!(GPUCompiler, "julia_passes" => "ports") +``` + +The preference takes effect after restarting Julia. While precompiling packages that do not +load JuliaIR.jl, configurations keep using the C++ passes. + +The ports require JuliaIR.jl to be loaded. When the preference selects them, loading +GPUCompiler also loads JuliaIR.jl if the active environment provides it; otherwise, load it +with `using JuliaIR`. They also require opaque pointers: on Julia 1.10 and 1.11, contexts +created by `JuliaContext()` use them when the preference selects the ports, and other +contexts must be created with `JuliaContext(; opaque_pointers=true)`. + +To run the test suite with the ports, set the preference in `test/LocalPreferences.toml`. diff --git a/ext/JuliaIRExt.jl b/ext/JuliaIRExt.jl new file mode 100644 index 000000000..ba410e936 --- /dev/null +++ b/ext/JuliaIRExt.jl @@ -0,0 +1,37 @@ +# Julia's LLVM passes as ported to Julia by JuliaIR.jl, for jobs that select them with +# `julia_passes=:ports`. Loading this extension alone does not change generated code. +module JuliaIRExt + +import GPUCompiler +import JuliaIR + +# The port of each of Julia's passes in GPUCompiler's pipeline, by the name of the +# `LLVM.Interop` constructor of the C++ pass (or the name it would have, see +# `GPUCompiler.CPP_PASS_NAMES`). +const PORTS = Dict{Symbol,String}( + :AllocOptPass => "jl-alloc-opt", + :CancellationLoweringPass => "jl-cancellation-lowering", + :CombineMulAddPass => "jl-combine-muladd", + :DemoteFloat16Pass => "jl-demote-float16", + :ExpandAtomicModifyPass => "jl-expand-atomic-modify", + :FinalLowerGCPass => "jl-final-lower-gc", + :GCInvariantVerifierPass => "jl-gc-invariant-verifier", + :JuliaLICMPass => "jl-licm", + :LateLowerGCPass => "jl-late-lower-gc-frame", + :LowerExcHandlersPass => "jl-lower-exc-handlers", + :LowerPTLSPass => "jl-lower-ptls", + :LowerSIMDLoopPass => "jl-lower-simdloop", + :PropagateJuliaAddrspacesPass => "jl-propagate-addrspaces", + :RemoveJuliaAddrspacesPass => "jl-remove-addrspaces", + :RemoveNIPass => "jl-remove-ni", +) + +function __init__() + available = JuliaIR.passes() + for port in values(PORTS) + port in available || error("JuliaIR.jl does not provide the pass $port") + end + GPUCompiler.pass_ports[] = GPUCompiler.PassPorts(pb -> JuliaIR.register!(pb), PORTS) +end + +end # module JuliaIRExt diff --git a/src/GPUCompiler.jl b/src/GPUCompiler.jl index 8231c94b7..07c1d7dc4 100644 --- a/src/GPUCompiler.jl +++ b/src/GPUCompiler.jl @@ -77,6 +77,7 @@ include("deprecated.jl") include("jlgen.jl") include("irgen.jl") include("determinism.jl") +include("julia_passes.jl") include("optim.jl") include("validation.jl") include("rtlib.jl") @@ -104,6 +105,9 @@ function __init__() Tracy.@register_tracepoints() end register_deferred_codegen() + + # last, as it loads other packages + load_julia_passes() end end # module diff --git a/src/driver.jl b/src/driver.jl index 32c00b117..599235a89 100644 --- a/src/driver.jl +++ b/src/driver.jl @@ -18,12 +18,15 @@ export JuliaContext # JuliaContext helper below, which returns a local context on Julia 1.9, and the global # unique context on all other versions. Once we only support Julia 1.9, we'll deprecate # this helper to a regular `Context()` call. -function JuliaContext(; opaque_pointers=nothing) +# +# By default, the context uses opaque pointers if configurations select the ports of Julia's +# passes by default, which require them, and LLVM's default otherwise. +function JuliaContext(; opaque_pointers=context_opaque_pointers(default_julia_passes())) # XXX: remove ThreadSafeContext(; opaque_pointers) end -function JuliaContext(f; kwargs...) - ThreadSafeContext(; kwargs...) do ts_ctx +function JuliaContext(f; opaque_pointers=context_opaque_pointers(default_julia_passes())) + ThreadSafeContext(; opaque_pointers) do ts_ctx # for now, also activate the underlying context # XXX: this is wrong; we can't expose the underlying LLVM context, but should # instead always go through the callback in order to unlock it properly. @@ -108,6 +111,7 @@ function compile_unhooked(output::Symbol, @nospecialize(job::CompilerJob); if context(; throw_error=false) === nothing error("No active LLVM context. Use `JuliaContext()` do-block syntax to create one.") end + check_julia_passes(job) @tracepoint "Validation" begin check_method(job) # not optional diff --git a/src/interface.jl b/src/interface.jl index 00dcfea30..a621b311a 100644 --- a/src/interface.jl +++ b/src/interface.jl @@ -143,7 +143,8 @@ else end const CONFIG_KWARGS = [:kernel, :name, :entry_abi, :always_inline, :opt_level, - :debug_level, :libraries, :optimize, :cleanup, :validate, :strip] + :debug_level, :libraries, :optimize, :cleanup, :validate, :strip, + :julia_passes] """ CompilerConfig(target, params; kernel=true, entry_abi=:specfunc, name=nothing, @@ -177,6 +178,11 @@ Several keyword arguments can be used to customize the compilation process: - `cleanup`: run cleanup passes on the code (default: true) - `validate`: enable optional validation of input and outputs (default: true) - `strip`: strip non-functional metadata and debug information (default: false) +- `julia_passes`: the implementation of Julia's LLVM passes (e.g. GC lowering) that + optimization uses: `:cpp` for the passes of Julia's code generator, or `:ports` for + their ports in JuliaIR.jl, which must then be loaded. The default comes from the + `julia_passes` preference of GPUCompiler (`:cpp` if unset). See + [`GPUCompiler.julia_pass`](@ref) for which passes are ported. """ struct CompilerConfig{T,P} target::T @@ -193,6 +199,7 @@ struct CompilerConfig{T,P} cleanup::Bool validate::Bool strip::Bool + julia_passes::Symbol # internal toplevel::Bool @@ -206,10 +213,14 @@ struct CompilerConfig{T,P} always_inline=false, opt_level=2, debug_level=Base.JLOptions().debug_level, optimize=toplevel, libraries=toplevel, cleanup=toplevel, validate=toplevel, - strip=false, only_entry=false) + strip=false, julia_passes=default_julia_passes(), + only_entry=false) if entry_abi ∉ (:specfunc, :func) error("Unknown entry_abi=$entry_abi") end + if julia_passes ∉ (:cpp, :ports) + error("Unknown julia_passes=$julia_passes") + end owner = @static if HAS_INTEGRATED_CACHE cache_owner(target, params, always_inline) else @@ -217,8 +228,8 @@ struct CompilerConfig{T,P} end new{typeof(target), typeof(params)}(target, params, kernel, name, entry_abi, always_inline, opt_level, debug_level, libraries, - optimize, cleanup, validate, strip, toplevel, - only_entry, owner) + optimize, cleanup, validate, strip, + julia_passes, toplevel, only_entry, owner) end end @@ -228,7 +239,8 @@ function CompilerConfig(cfg::CompilerConfig; target=cfg.target, params=cfg.param always_inline=cfg.always_inline, opt_level=cfg.opt_level, debug_level=cfg.debug_level, libraries=cfg.libraries, optimize=cfg.optimize, cleanup=cfg.cleanup, - validate=cfg.validate, strip=cfg.strip, toplevel=cfg.toplevel, + validate=cfg.validate, strip=cfg.strip, + julia_passes=cfg.julia_passes, toplevel=cfg.toplevel, only_entry=cfg.only_entry) # deriving a non-toplevel job disables certain features # XXX: should we keep track if any of these were set explicitly in the first place? @@ -240,8 +252,8 @@ function CompilerConfig(cfg::CompilerConfig; target=cfg.target, params=cfg.param validate = false end CompilerConfig(target, params; kernel, entry_abi, name, always_inline, opt_level, - debug_level, libraries, optimize, cleanup, validate, strip, toplevel, - only_entry) + debug_level, libraries, optimize, cleanup, validate, strip, + julia_passes, toplevel, only_entry) end function Base.show(io::IO, @nospecialize(cfg::CompilerConfig{T})) where {T} @@ -263,6 +275,7 @@ function Base.hash(cfg::CompilerConfig, h::UInt) h = hash(cfg.cleanup, h) h = hash(cfg.validate, h) h = hash(cfg.strip, h) + h = hash(cfg.julia_passes, h) h = hash(cfg.toplevel, h) h = hash(cfg.only_entry, h) # `cache_owner` deliberately is not hashed: its target/params/inlining components were diff --git a/src/irgen.jl b/src/irgen.jl index 679e8b23a..a07d59c69 100644 --- a/src/irgen.jl +++ b/src/irgen.jl @@ -620,6 +620,14 @@ function classify_arguments(@nospecialize(job::CompilerJob), codegen_ft::LLVM.Fu codegen_i += 1 end + # codegen omits ghost arguments, and `Type{T}` ones only if the signature uses + # `Core.TypeEgal` keys like GPUCompiler's `methodinstance` does; otherwise the arguments + # would be classified against the wrong parameters. after optimization, back-ends may + # have added parameters. + if !post_optimization && codegen_i - 1 != length(codegen_types) + error("Cannot classify the arguments of $(job.source): the signature has $(codegen_i - 1) non-ghost arguments, but the function generated for it has $(length(codegen_types)) parameters") + end + return args end diff --git a/src/julia_passes.jl b/src/julia_passes.jl new file mode 100644 index 000000000..c96a3e1e3 --- /dev/null +++ b/src/julia_passes.jl @@ -0,0 +1,88 @@ +# Julia's LLVM passes, from Julia's code generator or as ported to Julia by JuliaIR.jl + +# The default of `CompilerConfig`'s `julia_passes`. Set with +# `Preferences.set_preferences!(GPUCompiler, "julia_passes" => "ports")`. +const JULIA_PASSES = Symbol(@load_preference("julia_passes", "cpp")) +JULIA_PASSES in (:cpp, :ports) || + error("Invalid julia_passes preference $(repr(String(JULIA_PASSES))); expected \"cpp\" or \"ports\"") + +# The ports of Julia's passes, supplied by the JuliaIRExt extension: a function registering +# them with a `PassBuilder`, and the pipeline name of the port of each of Julia's passes by +# the name of the `LLVM.Interop` constructor of the C++ pass. +struct PassPorts + register!::Function + names::Dict{Symbol,String} +end +const pass_ports = Ref{Union{Nothing,PassPorts}}(nothing) + +# The default of `CompilerConfig`'s `julia_passes`: the preference, except while +# precompiling without JuliaIR.jl loaded, e.g., in the workload of a package that does not +# depend on it. That uses the C++ passes; since the setting is part of the configuration, +# what it caches is not used for configurations that select the ports. +function default_julia_passes() + if JULIA_PASSES === :ports && pass_ports[] === nothing && + ccall(:jl_generating_output, Cint, ()) == 1 + return :cpp + end + return JULIA_PASSES +end + +const JULIAIR = Base.PkgId(Base.UUID("3913dba9-7ba9-44aa-bf76-1cfa3b3073b1"), "JuliaIR") + +# When the preference selects the ports, load JuliaIR.jl if the environment provides it, +# so that code using GPUCompiler through other packages (e.g. their test suites) gets the +# ports without loading JuliaIR.jl itself. Its extension then registers the ports, once +# GPUCompiler has finished loading. +function load_julia_passes() + JULIA_PASSES === :ports || return + ccall(:jl_generating_output, Cint, ()) == 1 && return + haskey(Base.loaded_modules, JULIAIR) && return + Base.locate_package(JULIAIR) === nothing && return + Base.require(JULIAIR) + return +end + +function check_julia_passes(@nospecialize(job::CompilerJob)) + job.config.julia_passes === :ports || return + pass_ports[] === nothing && + error("julia_passes=:ports requires JuliaIR.jl to be loaded (`using JuliaIR`)") + supports_typed_pointers(context()) && + error("julia_passes=:ports requires a context with opaque pointers; create it with `JuliaContext(; opaque_pointers=true)`") + return +end + +# The `opaque_pointers` argument for contexts to compile in with `julia_passes`: the ports +# only support opaque pointers, which Julia 1.10 and 1.11 do not use by default. +context_opaque_pointers(julia_passes::Symbol) = julia_passes === :ports ? true : nothing + +""" + julia_pass(job, name::Symbol) + +The Julia pass `name` (the name of its constructor in `LLVM.Interop`, e.g. +`:AllocOptPass`) for use in the optimization pipeline of `job`: Julia's C++ pass, or its +port in JuliaIR.jl if `job.config.julia_passes == :ports`. Passes that `LLVM.Interop` has +no constructor for are named as if it had one, e.g. `:CancellationLoweringPass`. +""" +function julia_pass(@nospecialize(job::CompilerJob), name::Symbol) + if job.config.julia_passes === :ports + ports = pass_ports[]::PassPorts + haskey(ports.names, name) || error("Unknown Julia pass $name") + return ports.names[name] + end + isdefined(LLVM.Interop, name) && return getfield(LLVM.Interop, name)() + haskey(CPP_PASS_NAMES, name) || error("Unknown Julia pass $name") + return CPP_PASS_NAMES[name] +end + +# Julia's passes that `LLVM.Interop` has no constructor for: their names in the pipelines +# of Julia's code generator, which registers them with every `PassBuilder`. +const CPP_PASS_NAMES = Dict{Symbol,String}( + :CancellationLoweringPass => "CancellationLowering", +) + +# make the ports usable by name in the pipeline of `pb` +function register_julia_passes!(pb::PassBuilder, @nospecialize(job::CompilerJob)) + job.config.julia_passes === :ports || return + Base.invokelatest((pass_ports[]::PassPorts).register!, pb) + return +end diff --git a/src/optim.jl b/src/optim.jl index 9c426dbdb..bbc8c206d 100644 --- a/src/optim.jl +++ b/src/optim.jl @@ -57,6 +57,7 @@ function optimize!(@nospecialize(job::CompilerJob), mod::LLVM.Module, @dispose pb=PassBuilder() begin tti === nothing || LLVM.target_transform_info!(pb, tti) + register_julia_passes!(pb, job) register!(pb, GPULowerCPUFeaturesPass(job)) register!(pb, GPULowerPTLSPass(job)) register!(pb, GPULowerGCFramePass(job, relocs)) @@ -121,7 +122,7 @@ end function buildEarlySimplificationPipeline(mpm, @nospecialize(job::CompilerJob), opt_level) if should_verify() add!(mpm, FunctionPassManager()) do fpm - add!(fpm, GCInvariantVerifierPass()) + add!(fpm, julia_pass(job, :GCInvariantVerifierPass)) end add!(mpm, VerifierPass()) end @@ -135,7 +136,7 @@ function buildEarlySimplificationPipeline(mpm, @nospecialize(job::CompilerJob), add!(mpm, FunctionPassManager()) do fpm add!(fpm, LowerExpectIntrinsicPass()) if opt_level >= 2 - add!(fpm, PropagateJuliaAddrspacesPass()) + add!(fpm, julia_pass(job, :PropagateJuliaAddrspacesPass)) end # DCE must come before simplifycfg: codegen can generate unused # statements that would otherwise alter how simplifycfg optimizes the CFG. @@ -168,7 +169,7 @@ function buildEarlyOptimizerPipeline(mpm, @nospecialize(job::CompilerJob), opt_l end if opt_level >= 2 add!(cgpm, FunctionPassManager()) do fpm - add!(fpm, AllocOptPass()) + add!(fpm, julia_pass(job, :AllocOptPass)) add!(fpm, Float2IntPass()) add!(fpm, LowerConstantIntrinsicsPass()) end @@ -187,7 +188,7 @@ function buildEarlyOptimizerPipeline(mpm, @nospecialize(job::CompilerJob), opt_l add!(fpm, LibCallsShrinkWrapPass()) add!(fpm, ReassociatePass()) add!(fpm, ConstraintEliminationPass()) - add!(fpm, AllocOptPass()) + add!(fpm, julia_pass(job, :AllocOptPass)) else add!(fpm, EarlyCSEPass()) add!(fpm, instcombine_pass(job)) @@ -203,17 +204,17 @@ end function buildLoopOptimizerPipeline(fpm, @nospecialize(job::CompilerJob), opt_level) add!(fpm, LoopPassManager(; use_memory_ssa=true)) do lpm - add!(lpm, LowerSIMDLoopPass()) + add!(lpm, julia_pass(job, :LowerSIMDLoopPass)) if opt_level >= 2 add!(lpm, LoopInstSimplifyPass()) add!(lpm, LoopSimplifyCFGPass()) # run LICM with AllowSpeculation=false before rotation to avoid # speculating loads that rotation can hoist more precisely. add!(lpm, LICMPass(; allowspeculation=false)) - add!(lpm, JuliaLICMPass()) + add!(lpm, julia_pass(job, :JuliaLICMPass)) add!(lpm, LoopRotatePass()) add!(lpm, LICMPass()) - add!(lpm, JuliaLICMPass()) + add!(lpm, julia_pass(job, :JuliaLICMPass)) add!(lpm, SimpleLoopUnswitchPass(nontrivial=true, trivial=true)) end if LLVM.version() >= v"17" @@ -241,7 +242,7 @@ end function buildScalarOptimizerPipeline(fpm, @nospecialize(job::CompilerJob), opt_level) if opt_level >= 2 - add!(fpm, AllocOptPass()) + add!(fpm, julia_pass(job, :AllocOptPass)) add!(fpm, SROAPass()) if can_vectorize(job) add!(fpm, VectorCombinePass()) @@ -259,7 +260,7 @@ function buildScalarOptimizerPipeline(fpm, @nospecialize(job::CompilerJob), opt_ add!(fpm, JumpThreadingPass()) add!(fpm, ConstraintEliminationPass()) elseif opt_level >= 1 - add!(fpm, AllocOptPass()) + add!(fpm, julia_pass(job, :AllocOptPass)) add!(fpm, SROAPass()) add!(fpm, MemCpyOptPass()) add!(fpm, SCCPPass()) @@ -276,10 +277,10 @@ function buildScalarOptimizerPipeline(fpm, @nospecialize(job::CompilerJob), opt_ add!(fpm, PeepholeCallbacks(; opt_level)) end add!(fpm, simplifycfg_pass(job, AggressiveSimplifyCFGOptions)) - add!(fpm, AllocOptPass()) + add!(fpm, julia_pass(job, :AllocOptPass)) add!(fpm, LoopPassManager(; use_memory_ssa=true)) do lpm add!(lpm, LICMPass()) - add!(lpm, JuliaLICMPass()) + add!(lpm, julia_pass(job, :JuliaLICMPass)) end add!(fpm, simplifycfg_pass(job, AggressiveSimplifyCFGOptions)) add!(fpm, instcombine_pass(job)) @@ -326,7 +327,7 @@ function buildVectorPipeline(fpm, @nospecialize(job::CompilerJob), opt_level) end function buildIntrinsicLoweringPipeline(mpm, @nospecialize(job::CompilerJob), opt_level) - add!(mpm, RemoveNIPass()) + add!(mpm, julia_pass(job, :RemoveNIPass)) # lower GC intrinsics if !uses_julia_runtime(job) @@ -365,24 +366,30 @@ function buildIntrinsicLoweringPipeline(mpm, @nospecialize(job::CompilerJob), op add!(mpm, FunctionPassManager()) do fpm # lower exception handling if uses_julia_runtime(job) && VERSION < v"1.13.0-DEV.36" - add!(fpm, LowerExcHandlersPass()) + add!(fpm, julia_pass(job, :LowerExcHandlersPass)) end - add!(fpm, GCInvariantVerifierPass()) - add!(fpm, LateLowerGCPass()) + # lower the cancellation points of `Base.@cancel_check` to `setjmp`s into the task, + # before GC lowering (JuliaLang/julia#62604). Only Julia's runtime can deliver a + # cancellation. + if uses_julia_runtime(job) && VERSION >= v"1.14.0-DEV.2872" + add!(fpm, julia_pass(job, :CancellationLoweringPass)) + end + add!(fpm, julia_pass(job, :GCInvariantVerifierPass)) + add!(fpm, julia_pass(job, :LateLowerGCPass)) # FinalLowerGCPass moved from a module pass to a function pass in # JuliaLang/julia#51081 (1.11.0-DEV.208). if uses_julia_runtime(job) && VERSION >= v"1.11.0-DEV.208" - add!(fpm, FinalLowerGCPass()) + add!(fpm, julia_pass(job, :FinalLowerGCPass)) end # codegen emits atomic modifications (e.g. `@atomic x.f += 1`) as calls to the # `julia.atomicmodify` pseudo-intrinsic, which Julia expands after GC lowering # (JuliaLang/julia#57010) @static if VERSION >= v"1.13.0-DEV.321" - add!(fpm, ExpandAtomicModifyPass()) + add!(fpm, julia_pass(job, :ExpandAtomicModifyPass)) end end if uses_julia_runtime(job) && VERSION < v"1.11.0-DEV.208" - add!(mpm, FinalLowerGCPass()) + add!(mpm, julia_pass(job, :FinalLowerGCPass)) end if opt_level >= 2 @@ -395,7 +402,7 @@ function buildIntrinsicLoweringPipeline(mpm, @nospecialize(job::CompilerJob), op # lower PTLS intrinsics if uses_julia_runtime(job) - add!(mpm, LowerPTLSPass()) + add!(mpm, julia_pass(job, :LowerPTLSPass)) end if opt_level >= 1 @@ -413,7 +420,7 @@ function buildIntrinsicLoweringPipeline(mpm, @nospecialize(job::CompilerJob), op end # remove Julia address spaces - add!(mpm, RemoveJuliaAddrspacesPass()) + add!(mpm, julia_pass(job, :RemoveJuliaAddrspacesPass)) # Julia's operand bundles confuse the inliner, so repeat here now they are gone. # FIXME: we should fix the inliner so that inlined code gets optimized early-on @@ -427,7 +434,7 @@ function buildCleanupPipeline(mpm, @nospecialize(job::CompilerJob), opt_level) if opt_level >= 2 add!(mpm, FunctionPassManager()) do fpm if VERSION < v"1.12.0-DEV.1390" - add!(fpm, CombineMulAddPass()) + add!(fpm, julia_pass(job, :CombineMulAddPass)) end add!(fpm, DivRemPairsPass()) end @@ -439,7 +446,7 @@ function buildCleanupPipeline(mpm, @nospecialize(job::CompilerJob), opt_level) add!(fpm, AnnotationRemarksPass()) end add!(mpm, FunctionPassManager()) do fpm - add!(fpm, DemoteFloat16Pass()) + add!(fpm, julia_pass(job, :DemoteFloat16Pass)) if opt_level >= 2 add!(fpm, GVNPass()) end diff --git a/src/reflection.jl b/src/reflection.jl index 9fbc7a60f..4dc794582 100644 --- a/src/reflection.jl +++ b/src/reflection.jl @@ -264,7 +264,8 @@ function code_llvm(io::IO, @nospecialize(job::CompilerJob); optimize::Bool=job.c debuginfo::Symbol=:default, dump_module::Bool=false, kwargs...) # NOTE: jl_dump_function_ir supports stripping metadata, so don't do it in the driver config = CompilerConfig(job.config; validate=false, strip=false, optimize) - str = JuliaContext() do ctx + opaque_pointers = context_opaque_pointers(config.julia_passes) + str = JuliaContext(; opaque_pointers) do ctx ir, meta = compile(:llvm, CompilerJob(job; config)) ts_mod = ThreadSafeModule(ir) entry_fn = meta.entry @@ -297,7 +298,8 @@ See also: [`@device_code_native`](@ref), `InteractiveUtils.code_llvm` function code_native(io::IO, @nospecialize(job::CompilerJob); raw::Bool=false, dump_module::Bool=false) config = CompilerConfig(job.config; strip=!raw, only_entry=!dump_module, validate=false) - asm = JuliaContext() do ctx + opaque_pointers = context_opaque_pointers(config.julia_passes) + asm = JuliaContext(; opaque_pointers) do ctx local asm, meta = compile(:asm, CompilerJob(job; config)) dispose(meta.ir) asm diff --git a/src/rtlib.jl b/src/rtlib.jl index dd389b845..60cb5ad72 100644 --- a/src/rtlib.jl +++ b/src/rtlib.jl @@ -85,7 +85,10 @@ end mutable struct RuntimeFunctionResults bitcode::Union{Nothing,Vector{UInt8}} relocations::Relocations - RuntimeFunctionResults() = new(nothing, Relocations()) + # whether `bitcode` uses opaque pointers: on Julia 1.10 and 1.11, contexts can use + # either, and bitcode with opaque pointers can't be read in a typed context + opaque_pointers::Bool + RuntimeFunctionResults() = new(nothing, Relocations(), false) end # Compile a single runtime function and link it into `mod`. The renamed bitcode is @@ -99,7 +102,8 @@ function emit_function!(mod, relocs::Relocations, config::CompilerConfig, # Don't run a standalone inference walk on a miss: `compile_unhooked` below drives # inference itself. ci, res = runtime_function_results(rt_job) - if res !== nothing && res.bitcode !== nothing + opaque_pointers = !supports_typed_pointers(context()) + if res !== nothing && res.bitcode !== nothing && res.opaque_pointers == opaque_pointers link_relocatable!(mod, relocs, parse(LLVM.Module, res.bitcode), res.relocations) @@ -139,6 +143,7 @@ function emit_function!(mod, relocs::Relocations, config::CompilerConfig, res === nothing && (res = runtime_results(RuntimeFunctionResults, ci, rt_job.config)) res.bitcode = take!(io) res.relocations = meta.relocations + res.opaque_pointers = opaque_pointers end link_relocatable!(mod, relocs, new_mod, meta.relocations) diff --git a/src/spirv.jl b/src/spirv.jl index f23c1779a..ac44c9f88 100644 --- a/src/spirv.jl +++ b/src/spirv.jl @@ -375,7 +375,8 @@ source_code(target::SPIRVCompilerTarget) = "spirv" # reimplementation that uses the SPIR-V disassembler, giving much more pleasant output function code_native(io::IO, job::CompilerJob{SPIRVCompilerTarget}; raw::Bool=false, dump_module::Bool=false) config = CompilerConfig(job.config; strip=!raw, only_entry=!dump_module, validate=false) - obj = JuliaContext() do ctx + opaque_pointers = context_opaque_pointers(config.julia_passes) + obj = JuliaContext(; opaque_pointers) do ctx local obj, meta = compile(:obj, CompilerJob(job; config)) dispose(meta.ir) obj diff --git a/test/Project.toml b/test/Project.toml index 8d2eba274..5e5fe8905 100644 --- a/test/Project.toml +++ b/test/Project.toml @@ -6,6 +6,7 @@ FileCheck = "4e644321-382b-4b05-b0b6-5d23c3d944fb" GPUCompiler = "61eb1bfa-7361-4325-ad38-22787b887f55" Highlights = "eafb193a-b7ab-5a9e-9068-77385905fa72" InteractiveUtils = "b77e0a4c-d291-57a0-90e8-8db25a27a240" +JuliaIR = "3913dba9-7ba9-44aa-bf76-1cfa3b3073b1" LLVM = "929cbde3-209d-540e-8aea-75f648917ca0" LLVMDowngrader_jll = "f52de702-fb25-5922-94ba-81dd59b07444" Logging = "56ddb016-857b-54e1-b83d-db4d58db5568" @@ -23,4 +24,5 @@ demumble_jll = "1e29f10c-031c-5a83-9565-69cddfc27673" [compat] Aqua = "0.8" +JuliaIR = "0.1" ParallelTestRunner = "2" diff --git a/test/examples.jl b/test/examples.jl index aef6d4ccf..1e666edcb 100644 --- a/test/examples.jl +++ b/test/examples.jl @@ -17,7 +17,9 @@ filter!(file -> !occursin("Kaleidoscope", file), files) cd(dir) do examples = relpath.(files, Ref(dir)) @testset for example in examples - cmd = `$(Base.julia_cmd()) --project=$(Base.active_project())` + # with JuliaIR.jl loaded, for when the preference selects the ports of Julia's passes + cmd = `$(Base.julia_cmd()) --project=$(Base.active_project()) + -e 'using JuliaIR; include(popfirst!(ARGS))'` @test success(pipeline(`$cmd $example`, stderr=stderr)) end end diff --git a/test/gcn.jl b/test/gcn.jl index 62321bfb0..3c39f6b3e 100644 --- a/test/gcn.jl +++ b/test/gcn.jl @@ -561,7 +561,7 @@ end # the back-end crashes the process, so generate code in another one script = """ - using GPUCompiler, LLVM + using GPUCompiler, LLVM, JuliaIR include($(repr(joinpath(@__DIR__, "helpers", "runtime.jl")))) include($(repr(joinpath(@__DIR__, "helpers", "gcn.jl")))) kernel_i8(p::Core.LLVMPtr{Int8,1}, x::Int8) = diff --git a/test/julia_passes.jl b/test/julia_passes.jl new file mode 100644 index 000000000..e45a1481c --- /dev/null +++ b/test/julia_passes.jl @@ -0,0 +1,306 @@ +@testset "configuration" begin + job, _ = PTX.create_job(identity, (Int,)) + @test job.config.julia_passes === GPUCompiler.JULIA_PASSES + + cpp, _ = PTX.create_job(identity, (Int,); julia_passes=:cpp) + ports, _ = PTX.create_job(identity, (Int,); julia_passes=:ports) + @test cpp.config.julia_passes === :cpp + @test ports.config.julia_passes === :ports + # part of the identity of cached artifacts + @test cpp.config != ports.config + @test hash(cpp.config) != hash(ports.config) + # preserved by derived configs, e.g., of nested jobs + @test CompilerConfig(ports.config; toplevel=false).julia_passes === :ports + + @test_throws "Unknown julia_passes=llvm" PTX.create_job(identity, (Int,); julia_passes=:llvm) +end + +@testset "providers" begin + cpp, _ = PTX.create_job(identity, (Int,); julia_passes=:cpp) + ports, _ = PTX.create_job(identity, (Int,); julia_passes=:ports) + @test string(GPUCompiler.julia_pass(cpp, :AllocOptPass)) == "AllocOpt" + @test GPUCompiler.julia_pass(ports, :AllocOptPass) == "jl-alloc-opt" + @test_throws "Unknown Julia pass" GPUCompiler.julia_pass(ports, :NoSuchPass) + @test_throws "Unknown Julia pass" GPUCompiler.julia_pass(cpp, :NoSuchPass) + # passes that LLVM.jl has no constructor for, by their pipeline name + @test GPUCompiler.julia_pass(cpp, :CancellationLoweringPass) == "CancellationLowering" + + # every one of Julia's passes in the pipeline has a port + @test GPUCompiler.julia_pass(ports, :JuliaLICMPass) == "jl-licm" + @test GPUCompiler.julia_pass(ports, :FinalLowerGCPass) == "jl-final-lower-gc" + @test GPUCompiler.julia_pass(ports, :LowerPTLSPass) == "jl-lower-ptls" + @test GPUCompiler.julia_pass(ports, :RemoveJuliaAddrspacesPass) == "jl-remove-addrspaces" + @test GPUCompiler.julia_pass(ports, :CancellationLoweringPass) == "jl-cancellation-lowering" + @test length(GPUCompiler.pass_ports[].names) == 15 +end + +@testset "requirements" begin + kernel() = nothing + job, _ = PTX.create_job(kernel, (); julia_passes=:ports) + + ports = GPUCompiler.pass_ports[] + try + GPUCompiler.pass_ports[] = nothing + JuliaContext(; opaque_pointers=true) do ctx + @test_throws "requires JuliaIR.jl to be loaded" GPUCompiler.compile(:llvm, job) + end + finally + GPUCompiler.pass_ports[] = ports + end + + if VERSION < v"1.12" + JuliaContext(; opaque_pointers=false) do ctx + @test_throws "requires a context with opaque pointers" GPUCompiler.compile(:llvm, job) + end + end + + # reflection picks a suitable context + @test occursin("define", sprint(io -> GPUCompiler.code_llvm(io, job))) +end + +@testset "loading" begin + # with the preference selecting the ports, loading GPUCompiler loads JuliaIR.jl from + # the environment, and the extension registers the ports + code = "import GPUCompiler; print(GPUCompiler.pass_ports[] !== nothing)" + out = read(`$(Base.julia_cmd()) --startup-file=no --project=$(Base.active_project()) -e $code`, String) + @test out == string(GPUCompiler.JULIA_PASSES === :ports) +end + +# Packages that do not depend on JuliaIR.jl may compile code while precompiling, e.g., in +# their workload, in a process without it. +VERSION >= v"1.11" && precompile_test_harness("precompilation without JuliaIR.jl") do load_path + # not named like the package of test/native/precompile.jl, which a worker may have + # loaded already, so that the cache of the workload would not match it + create_standalone(load_path, "PortsCompiler", "native.jl") + write(joinpath(load_path, "PortsWorkload.jl"), :( + module PortsWorkload + import PortsCompiler + using PortsCompiler.GPUCompiler + + kernel(x) = x + 1 + const julia_passes = let + job, _ = PortsCompiler.Native.create_job(kernel, (Int,)) + JuliaContext() do ctx + GPUCompiler.compile(:llvm, job) + end + job.config.julia_passes + end + end) |> string) + Base.compilecache(Base.PkgId("PortsWorkload")) + + # such compilations use the C++ passes; that is part of their configuration, so + # results cached for them are not used for configurations with the ports + mod = Base.require(Base.PkgId("PortsWorkload")) + @test Base.invokelatest(getglobal, mod, :julia_passes) === :cpp +end + +## differential: the C++ passes and the ports give the same IR + +# Two compilations differ in the counters codegen appends to the names of functions (and +# of the symbols derived from them in assembly), e.g. `julia_kernel_123`. +normalize_ir(ir::String) = + replace(ir, r"(? s"\1\2_\3_N") + +# The IR of a kernel. Without validation, which rejects some of the corpus on some targets +# (e.g. `Float64` on Metal) after optimization. +function compile_with(julia_passes, backend, f, tt; kwargs...) + JuliaContext(; opaque_pointers=true) do ctx + job, _ = backend.create_job(f, tt; julia_passes, validate=false, kwargs...) + ir, meta = GPUCompiler.compile(:llvm, job) + str = string(ir) + LLVM.dispose(ir) + normalize_ir(str) + end +end + +function same_ir(backend, f, tt; kwargs...) + cpp = compile_with(:cpp, backend, f, tt; kwargs...) + ports = compile_with(:ports, backend, f, tt; kwargs...) + cpp == ports && return true + @error "IR differs between the C++ passes and the ports" f tt kwargs cpp ports + return false +end + +module Corpus + mutable struct Box; x::Int; end + struct Pair2; a::Float32; b::Int128; end + + alloc(p, i) = (b = Box(i); unsafe_store!(p, b.x + 1); nothing) + ref(p, i) = (r = Ref(i); unsafe_store!(p, r[] * 2); nothing) + function simd(p, n) + s = 0.0f0 + @simd for i in 1:n + @inbounds s += unsafe_load(p, i) + end + unsafe_store!(p, s) + nothing + end + f16(p::Ptr{Float16}, x::Float16) = (unsafe_store!(p, x * x + Float16(1)); nothing) + throws(p, i) = (i > 10 && throw(BoundsError()); unsafe_store!(p, i); nothing) + i128(p::Ptr{Int128}, x::Int128) = (unsafe_store!(p, x * x); nothing) + tuple(p, t::NTuple{4,Float32}) = (unsafe_store!(p, sum(t)); nothing) + aggregate(p::Ptr{Pair2}, x::Pair2) = (unsafe_store!(p, Pair2(x.a + 1f0, x.b)); nothing) + function loop(p, n) + for i in 1:n, j in 1:n + unsafe_store!(p, unsafe_load(p, i) + j, i) + end + nothing + end + closure(p, x) = (f = y -> y + x; unsafe_store!(p, f(1)); nothing) + complex(p::Ptr{ComplexF32}, z::ComplexF32) = (unsafe_store!(p, z * z + abs(z)); nothing) + math(p::Ptr{Float64}, x::Float64) = + (unsafe_store!(p, sqrt(abs(x)) + muladd(x, x, 1.0) + copysign(x, -1.0)); nothing) + div(p::Ptr{Int}, a::Int, b::Int) = (unsafe_store!(p, Base.div(a, b) + rem(a, b)); nothing) + + # with device pointers, for targets that reject host pointers in kernels + device_alloc(p::Core.LLVMPtr{Int,1}, i) = (b = Box(i); unsafe_store!(p, b.x + 1); nothing) + device_tuple(p::Core.LLVMPtr{Float32,1}, t::NTuple{4,Float32}) = + (unsafe_store!(p, sum(t)); nothing) + function device_simd(p::Core.LLVMPtr{Float32,1}, n::Int32) + s = 0.0f0 + @simd for i in Int32(1):n + @inbounds s += unsafe_load(p, i) + end + unsafe_store!(p, s) + nothing + end + + const DEVICE_KERNELS = [ + (device_alloc, (Core.LLVMPtr{Int,1}, Int)), + (device_tuple, (Core.LLVMPtr{Float32,1}, NTuple{4,Float32})), + (device_simd, (Core.LLVMPtr{Float32,1}, Int32)), + ] + + const KERNELS = [ + (alloc, (Ptr{Int}, Int)), (ref, (Ptr{Int}, Int)), (simd, (Ptr{Float32}, Int)), + (f16, (Ptr{Float16}, Float16)), (throws, (Ptr{Int}, Int)), + (i128, (Ptr{Int128}, Int128)), (tuple, (Ptr{Float32}, NTuple{4,Float32})), + (aggregate, (Ptr{Pair2}, Pair2)), (loop, (Ptr{Int}, Int)), + (closure, (Ptr{Int}, Int)), (complex, (Ptr{ComplexF32}, ComplexF32)), + (math, (Ptr{Float64}, Float64)), (div, (Ptr{Int}, Int, Int)), + ] +end + +@testset "ports run" begin + # JuliaIR.jl records every pass it runs in its ledger + function passes_run(julia_passes, f, tt; backend=PTX, kernel=true) + ledger = tempname() + old = JuliaIR.LEDGER[] + try + JuliaIR.LEDGER[] = ledger + compile_with(julia_passes, backend, f, tt; kernel) + finally + JuliaIR.LEDGER[] = old + end + isfile(ledger) || return Set{String}() + Set(split(line, '\t')[3] for line in eachline(ledger)) + end + + @test isempty(passes_run(:cpp, Corpus.simd, (Ptr{Float32}, Int))) + ran = union(passes_run(:ports, Corpus.simd, (Ptr{Float32}, Int)), + passes_run(:ports, Corpus.alloc, (Ptr{Int}, Int))) + expected = ["jl-alloc-opt", "jl-demote-float16", "jl-gc-invariant-verifier", + "jl-late-lower-gc-frame", "jl-licm", "jl-lower-simdloop", + "jl-propagate-addrspaces", "jl-remove-addrspaces", "jl-remove-ni"] + VERSION < v"1.12.0-DEV.1390" && push!(expected, "jl-combine-muladd") + VERSION >= v"1.13.0-DEV.321" && push!(expected, "jl-expand-atomic-modify") + @test sort!(collect(ran)) == sort!(expected) + + # targets that use Julia's runtime also lower the GC frame and the GC stack getters + ran = passes_run(:ports, Corpus.alloc, (Ptr{Int}, Int); backend=Native, kernel=false) + @test "jl-final-lower-gc" in ran + @test "jl-lower-ptls" in ran + @test ("jl-cancellation-lowering" in ran) == (VERSION >= v"1.14.0-DEV.2872") +end + +# Compiling with both providers in opaque-pointer contexts, so that the comparison is +# independent of the typed pointers of Julia 1.10 and 1.11 (see below). +describe(kwargs) = join(("$k=$v" for (k, v) in pairs(kwargs)), " ") + +function differential_cases() + cases = Any[] + backends = Any[("PTX", PTX, (;)), ("SPIR-V", SPIRV, (; backend=:llvm)), + ("Metal", Metal, (;)), ("native", Native, (;)), ("BPF", BPF, (;))] + # see runtests.jl. the GCN datalayout comes from the in-process AMDGPU target, which + # Julia's LLVM lacks on macOS; without it, allocas stay in addrspace(0), which LLVM 23's + # verifier rejects for amdgcn. + if !LLVM.is_asserts() && :AMDGPU in LLVM.backends() + push!(backends, ("GCN", GCN, (;))) + end + for (name, backend, kwargs) in backends, + (f, tt) in [Corpus.KERNELS; Corpus.DEVICE_KERNELS], kernel in (false, true) + # Metal kernels can't take host pointers + name == "Metal" && kernel && (f, tt) ∉ Corpus.DEVICE_KERNELS && continue + push!(cases, (name, backend, f, tt, (; kernel, kwargs...))) + end + return cases +end + +@testset "differential" begin + @testset "$name $(nameof(f)) $(describe(kwargs))" for (name, backend, f, tt, kwargs) in differential_cases() + @test same_ir(backend, f, tt; kwargs...) + end + @testset "PTX opt_level=$opt_level" for opt_level in (0, 1, 3) + @test same_ir(PTX, Corpus.simd, (Ptr{Float32}, Int); kernel=true, opt_level) + @test same_ir(PTX, Corpus.alloc, (Ptr{Int}, Int); kernel=true, opt_level) + end +end + +# The ports require opaque pointers, so with them, Julia 1.10 and 1.11 generate code in +# opaque-pointer contexts instead of typed ones. Judge that switch separately, with the C++ +# passes and down to machine code. Not when Julia itself uses opaque pointers +# (`--opaque-pointers`), as its code generator then does not support typed contexts. +@static if VERSION < v"1.12" && Context(LLVM.supports_typed_pointers) +@testset "typed and opaque pointers" begin + function asm_with(opaque_pointers, backend, f, tt; kwargs...) + JuliaContext(; opaque_pointers) do ctx + job, _ = backend.create_job(f, tt; julia_passes=:cpp, validate=false, kwargs...) + asm, meta = GPUCompiler.compile(:asm, job) + LLVM.dispose(meta.ir) + # without comments, which name basic blocks + replace(normalize_ir(String(asm)), r"//.*" => "", r"[ \t]+" => " ") + end + end + + # the back-ends that generate machine code outside of Julia's LLVM, see runtests.jl + have_ptx = NVPTX_LLVM_Backend_jll.is_available() + have_gcn = AMDGPU_LLVM_Backend_jll.is_available() && !LLVM.is_asserts() + have_spirv = SPIRV_LLVM_Backend_jll.is_available() && + SPIRV_LLVM_Translator_jll.is_available() && SPIRV_Tools_jll.is_available() + + have_ptx && @testset "PTX $(nameof(f)) kernel=$kernel" for (f, tt) in [Corpus.KERNELS; + Corpus.DEVICE_KERNELS], + kernel in (false, true) + if f === Corpus.aggregate && !kernel && Base.datatype_alignment(Int128) > 8 + # Where Julia aligns Int128 to 16 bytes before 1.12 (AArch64, not x86), the + # padding of the aggregate argument, passed by reference, is copied with + # opaque pointers but not with typed ones; only padding bytes differ. + @test asm_with(false, PTX, f, tt; kernel) isa String + @test asm_with(true, PTX, f, tt; kernel) isa String + else + @test asm_with(false, PTX, f, tt; kernel) == asm_with(true, PTX, f, tt; kernel) + end + end + + # elsewhere, the code differs in register allocation (native), debug locations (GCN) + # or the form of instructions (Metal's downgraded IR), so only require that it is + # generated. Metal and SPIR-V kernels can't take host pointers. + cases = Any[] + for (f, tt) in Corpus.KERNELS, kernel in (false, true) + push!(cases, ("native", Native, f, tt, (; kernel))) + have_gcn && push!(cases, ("GCN", GCN, f, tt, (; kernel))) + kernel || push!(cases, ("Metal", Metal, f, tt, (; kernel))) + end + for (f, tt) in Corpus.DEVICE_KERNELS, kernel in (false, true) + push!(cases, ("Metal", Metal, f, tt, (; kernel))) + if have_spirv + push!(cases, ("SPIR-V", SPIRV, f, tt, (; kernel, backend=:llvm))) + push!(cases, ("SPIR-V", SPIRV, f, tt, (; kernel, backend=:khronos))) + end + end + @testset "$name $(nameof(f)) $(describe(kwargs))" for (name, backend, f, tt, kwargs) in cases + @test asm_with(false, backend, f, tt; kwargs...) isa String + @test asm_with(true, backend, f, tt; kwargs...) isa String + end +end +end diff --git a/test/metal.jl b/test/metal.jl index 658a79e6c..1c5a2f9be 100644 --- a/test/metal.jl +++ b/test/metal.jl @@ -92,7 +92,7 @@ end Tuple{Core.LLVMPtr{Int32,1}, Core.LLVMPtr{Int32,1}, Int32, Int32, Int}; dump_module=true, kernel=true) end - @test Context() do ctx + @test JuliaContext() do ctx verify(parse(LLVM.Module, ir)) true end @@ -126,7 +126,7 @@ end # (by position, as older Julia versions do not name the parameters) function kernel_parameter_attributes(ir) - Context() do ctx + JuliaContext() do ctx m = parse(LLVM.Module, ir) f = only(filter(f -> startswith(f.name, "_Z6kernel"), collect(m.functions))) [Dict(LLVM.kind(attr) => LLVM.value(attr) @@ -529,7 +529,7 @@ end ret void } """ - @dispose ctx=Context() begin + JuliaContext() do ctx mod = parse(LLVM.Module, ir) GPUCompiler.merge_byte_gep_chains!(mod) f = mod.functions["k"] @@ -3150,7 +3150,10 @@ end source = methodinstance(typeof(mod.kernel), Tuple{Core.LLVMPtr{Float32,1}, Float32}, Base.get_world_counter()) target = MetalCompilerTarget(; macos=v"15", metal=v"3.2", air=v"2.7") - job = CompilerJob(source, CompilerConfig(target, Metal.CompilerParams(); kernel=true)) + # JuliaIR.jl's `AllocOpt` uses the escape analysis of 1.13 on every version, which + # removes the object altogether, so test the lowering with Julia's C++ passes + job = CompilerJob(source, CompilerConfig(target, Metal.CompilerParams(); kernel=true, + julia_passes=:cpp)) @test @filecheck begin @check "alloca" @check "cmpxchg" diff --git a/test/metal/reproducibility.jl b/test/metal/reproducibility.jl index b0a6eb13e..606ca1308 100644 --- a/test/metal/reproducibility.jl +++ b/test/metal/reproducibility.jl @@ -8,7 +8,7 @@ helpers = joinpath(@__DIR__, "..", "helpers") script = """ - using GPUCompiler, LLVM, LLVMDowngrader_jll + using GPUCompiler, LLVM, LLVMDowngrader_jll, JuliaIR include(joinpath($(repr(helpers)), "runtime.jl")) include(joinpath($(repr(helpers)), "metal.jl")) diff --git a/test/native.jl b/test/native.jl index 8bf1008a6..64a342b74 100644 --- a/test/native.jl +++ b/test/native.jl @@ -346,6 +346,19 @@ end @test Base.isdispatchtuple(mi.specTypes) end +@testset "argument classification checks the arity" begin + # e.g. codegen passing a `Type{T}` argument that the signature does not key as + # `Core.TypeEgal`, which `classify_arguments` treats as a ghost + job, _ = Native.create_job(identity, (Int,)) + JuliaContext() do ctx + ft = LLVM.FunctionType(LLVM.VoidType(), [LLVM.Int64Type(), LLVM.Int64Type()]) + @test_throws "the function generated for it has 2 parameters" GPUCompiler.classify_arguments(job, ft) + ft = LLVM.FunctionType(LLVM.VoidType(), [LLVM.Int64Type()]) + args = GPUCompiler.classify_arguments(job, ft) + @test [arg.idx for arg in args] == [nothing, 1] + end +end + # on 1.10, `methodinstance` is the generic, allocating lookup VERSION >= v"1.11.0-DEV.1552" && @testset "method instance lookups don't allocate" begin # back-ends look up the method instance on every kernel launch @@ -1043,6 +1056,23 @@ end end end +@static if VERSION >= v"1.14.0-DEV.2872" +@testset "cancellation points" begin + # from Julia 1.14, `@cancel_check` emits a `julia.cancellation_point()` call, which + # has to be lowered to a `setjmp` into the task (JuliaLang/julia#62604) + mod = @eval module $(gensym()) + f(x) = (Base.@cancel_check; x + 1) + end + + @testset "$julia_passes" for julia_passes in (:cpp, :ports) + @test @filecheck implicit_check_not=["julia.cancellation_point"] begin + @check "setjmp" + Native.code_llvm(mod.f, Tuple{Int}; julia_passes, dump_module=true) + end + end +end +end + @testset "tracked pointers" begin mod = @eval module $(gensym()) function kernel(a) diff --git a/test/native/precompile.jl b/test/native/precompile.jl index 675c9fa0b..194bf6fb1 100644 --- a/test/native/precompile.jl +++ b/test/native/precompile.jl @@ -110,8 +110,10 @@ precompile_test_harness("Inference caching") do load_path using NativeBackend + # precompilation without JuliaIR.jl uses Julia's C++ passes, also when the + # preference selects the ports persistent_job, _ = NativeCompiler.Native.create_job( - NativeBackend.persistent_kernel, (Int,); relocations=:patch) + NativeBackend.persistent_kernel, (Int,); relocations=:patch, julia_passes=:cpp) persistent_res = GPUCompiler.cached_results(NativeBackend.Results, persistent_job) @test persistent_res !== nothing if GPUCompiler.supports_relocatable_ir() diff --git a/test/runtests.jl b/test/runtests.jl index 44fd5629f..a66c92ce7 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -2,12 +2,16 @@ using ParallelTestRunner using Test import GPUCompiler, LLVM using GPUCompiler, LLVM +# provides the ports of Julia's passes; loading it changes nothing unless they are selected +# (with the `julia_passes` preference, or per test) +using JuliaIR using SPIRV_LLVM_Backend_jll, SPIRV_LLVM_Translator_jll, SPIRV_Tools_jll using NVPTX_LLVM_Backend_jll using AMDGPU_LLVM_Backend_jll const init_code = quote using GPUCompiler, LLVM, LLVM.IR, LLVM.Build, LLVM.Passes, LLVM.ORC + using JuliaIR using SPIRV_LLVM_Backend_jll, SPIRV_LLVM_Translator_jll, SPIRV_Tools_jll using LLVMDowngrader_jll using NVPTX_LLVM_Backend_jll diff --git a/test/spirv/atomics.jl b/test/spirv/atomics.jl index f1ca7f373..431e38701 100644 --- a/test/spirv/atomics.jl +++ b/test/spirv/atomics.jl @@ -717,7 +717,7 @@ end # e.g. when reflecting, which compiles without validation. the Khronos translator can exit # the process on what it doesn't support, so try that in another process script = """ - using GPUCompiler, LLVM + using GPUCompiler, LLVM, JuliaIR include($(repr(joinpath(@__DIR__, "..", "helpers", "runtime.jl")))) include($(repr(joinpath(@__DIR__, "..", "helpers", "spirv.jl")))) import .SPIRV: Atomics diff --git a/test/spirv/imaging.jl b/test/spirv/imaging.jl index 17639fbb3..ad9f3185d 100644 --- a/test/spirv/imaging.jl +++ b/test/spirv/imaging.jl @@ -3,7 +3,7 @@ # mode in a separate process, because code coverage disables package images, and with them # imaging mode during precompilation. script = """ - using GPUCompiler, LLVM, LLVM.IR + using GPUCompiler, LLVM, LLVM.IR, JuliaIR include($(repr(joinpath(@__DIR__, "..", "helpers", "runtime.jl")))) include($(repr(joinpath(@__DIR__, "..", "helpers", "spirv.jl"))))