diff --git a/.ci/juliacodegen.jl b/.ci/juliacodegen.jl new file mode 100644 index 000000000..8acdfc1f0 --- /dev/null +++ b/.ci/juliacodegen.jl @@ -0,0 +1,178 @@ +# TEMPORARY: sets up CI environments with the unreleased LLVM.jl, JuliaIR.jl and +# JuliaCodegen.jl that the `codegen=:juliacodegen` option needs, until those are released. +# Drop this file, and its uses in `.github/workflows/Test.yml`, once GPUCompiler can depend +# on registered versions. +# +# LLVM.jl's branch comes from `[sources]` in `Project.toml`; it is checked out (to `LLVM.jl/` +# in this repository) and developed explicitly rather than through `[sources]`, because its +# LLVMExtra library has to be built from the same checkout, and because Julia 1.10 ignores +# `[sources]`. JuliaCodegen.jl and JuliaIR.jl (which it depends on) come from the branches +# below; they are added to the test environment only on Julia 1.12 and later, which +# JuliaCodegen.jl requires. +# +# julia .ci/juliacodegen.jl build +# +# Checks out LLVM.jl and builds its LLVMExtra library if the checkout has wrapper changes +# that no LLVMExtra_jll release has (LLVM.jl's `deps/build_ci.jl`). Needs git, cmake (or +# downloads CMake_jll) and a C++ compiler; on Windows, run it in an MSYS2 shell. +# +# julia .ci/juliacodegen.jl test (julia|juliacodegen) +# +# Prepares GPUCompiler's test environment (`test/`), for `julia --project=test +# test/runtests.jl`, with the `codegen` preference set to the given code generator. Also +# develops LLVM.jl into the default environment (`@v#.#`), with its preferences. +# +# julia .ci/juliacodegen.jl check-ledger FILE +# +# Fails unless FILE, the ledger of the JuliaCodegenExt extension (`JULIACODEGEN_LEDGER`) +# of a run with JuliaCodegen.jl, shows that it compiled code, and reports how much of it +# fell back to native code. + +using Pkg, TOML + +const ROOT = dirname(@__DIR__) +const LLVM_DIR = joinpath(ROOT, "LLVM.jl") + +# the branches of the unreleased packages +const JULIACODEGEN = (; url="https://github.com/JuliaLLVM/JuliaCodegen.jl", rev="vc/jc-24-gpu-gaps") +const JULIAIR = (; url="https://github.com/JuliaLLVM/JuliaIR.jl", rev="vc/runtime-cfunction") + +function source(project, name) + toml = TOML.parsefile(joinpath(ROOT, project, "Project.toml")) + return toml["sources"][name] +end + +# remove the `[sources]` entries of the packages this script develops, which Pkg 1.11 +# refuses to replace ("`path` and `url` are conflicting") +function strip_sources(project) + file = joinpath(ROOT, project, "Project.toml") + toml = TOML.parsefile(file) + sources = get(toml, "sources", Dict()) + delete!(sources, "LLVM") + isempty(sources) && delete!(toml, "sources") + open(io -> TOML.print(io, toml), file, "w") +end + +run_git(args) = run(`git $args`) + +function build() + llvm = source(".", "LLVM") + if !isdir(LLVM_DIR) + # the full history, as build_ci.jl compares it with LLVMExtra_jll's releases + run_git(`clone --quiet --branch $(llvm["rev"]) $(llvm["url"]) $LLVM_DIR`) + end + run_git(`-C $LLVM_DIR log -1 --format='LLVM.jl %H %s'`) + # left by a build for another Julia version, which build_ci.jl may not replace + rm(joinpath(LLVM_DIR, "LocalPreferences.toml"); force=true) + Pkg.activate(LLVM_DIR) + Pkg.instantiate() + + if Sys.which("cmake") === nothing + # a wrapper on PATH, as build_local.jl looks for `cmake` there + Pkg.activate(; temp=true) + Pkg.add("CMake_jll") + @eval using CMake_jll + bin = mktempdir() + libpath = Base.invokelatest(() -> CMake_jll.LIBPATH[]) + exe = Base.invokelatest(() -> CMake_jll.cmake_path) + write(joinpath(bin, "cmake"), """ + #!/bin/sh + LD_LIBRARY_PATH="$libpath:\$LD_LIBRARY_PATH" exec "$exe" "\$@" + """) + chmod(joinpath(bin, "cmake"), 0o755) + ENV["PATH"] = bin * (Sys.iswindows() ? ";" : ":") * ENV["PATH"] + end + + run(`$(Base.julia_cmd()) --project=$(joinpath(LLVM_DIR, "deps")) $(joinpath(LLVM_DIR, "deps", "build_ci.jl"))`) + prefs = joinpath(LLVM_DIR, "LocalPreferences.toml") + if isfile(prefs) + print(read(prefs, String)) + else + println("Using LLVMExtra_jll") + end +end + +# the preferences for an environment: LLVM.jl's LLVMExtra build, if any, and the code +# generator +function write_preferences(dir, mode) + prefs = Dict{String,Any}() + llvm_prefs = joinpath(LLVM_DIR, "LocalPreferences.toml") + isfile(llvm_prefs) && merge!(prefs, TOML.parsefile(llvm_prefs)) + prefs["GPUCompiler"] = Dict("codegen" => mode) + file = joinpath(dir, "LocalPreferences.toml") + open(io -> TOML.print(io, prefs), file, "w") + println("$file:") + print(read(file, String)) +end + +function check_mode(mode) + mode in ("julia", "juliacodegen") || + error("Unknown code generator $mode; expected julia or juliacodegen") + mode == "juliacodegen" && VERSION < v"1.12" && + error("JuliaCodegen.jl requires Julia 1.12 or later") +end + +function test(mode) + check_mode(mode) + isdir(LLVM_DIR) || error("Run `julia .ci/juliacodegen.jl build` first") + strip_sources(".") + write_preferences(joinpath(ROOT, "test"), mode) + + Pkg.activate(joinpath(ROOT, "test")) + if VERSION >= v"1.12" + # first, as they are not registered: any other operation would need to resolve them + Pkg.add([PackageSpec(; JULIAIR...), PackageSpec(; JULIACODEGEN...)]) + end + specs = [PackageSpec(path=LLVM_DIR)] + # Julia 1.10 and 1.11 have no workspaces, so the test environment needs GPUCompiler + VERSION < v"1.12" && pushfirst!(specs, PackageSpec(path=ROOT)) + Pkg.develop(specs) + Pkg.status() + + # Aqua's persistent-task check loads GPUCompiler in a new environment, stacked only on + # the default one, so that needs LLVM.jl and the preference of its LLVMExtra too + Pkg.activate() + Pkg.develop(path=LLVM_DIR) + write_preferences(dirname(Base.active_project()), mode) +end + +function check_ledger(file) + isfile(file) || error("No JuliaCodegen.jl ledger at $file: it did not run") + jobs = 0 + functions = 0 + fallbacks = 0 + pids = Set{String}() + for (i, line) in enumerate(eachline(file)) + # PID, "juliacodegen", the method instance, the functions, the fallbacks + fields = split(line, '\t') + if !(length(fields) == 5 && all(isdigit, fields[1]) && fields[2] == "juliacodegen" && + all(isdigit, fields[4]) && all(isdigit, fields[5])) + error("Malformed line $i in the ledger at $file: $(repr(line))") + end + jobs += 1 + functions += parse(Int, fields[4]) + fallbacks += parse(Int, fields[5]) + push!(pids, fields[1]) + end + println("$jobs compilations of $functions functions, $fallbacks of which call native code, in $(length(pids)) processes") + jobs > 0 || error("JuliaCodegen.jl compiled nothing; is GPUCompiler using Julia's code generator?") +end + +function main(args) + # the environments are only complete at the end, e.g., JuliaIR.jl fails to precompile + # with the registered LLVM.jl that is used until the checkout is developed + ENV["JULIA_PKG_PRECOMPILE_AUTO"] = "0" + isempty(args) && error("Usage: julia .ci/juliacodegen.jl (build | test MODE | check-ledger FILE)") + command = args[1] + if command == "build" + build() + elseif command == "test" + test(args[2]) + 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..1a2199b8f 100644 --- a/.github/workflows/Test.yml +++ b/.github/workflows/Test.yml @@ -13,42 +13,60 @@ concurrency: jobs: release_test: - name: Julia ${{ matrix.version }} ${{ matrix.llvm_args }} - ${{ matrix.os }} + name: Julia ${{ matrix.version }} ${{ matrix.llvm_args }} - ${{ matrix.os }} - ${{ matrix.codegen }} runs-on: ${{ matrix.os }} - timeout-minutes: 20 + # TEMPORARY: raised from 20 for building LLVMExtra + timeout-minutes: 45 + # JuliaCodegen.jl is experimental: its jobs report, and don't fail the workflow + continue-on-error: ${{ matrix.codegen == 'juliacodegen' }} strategy: fail-fast: false matrix: 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 code generator (the `codegen` preference); JuliaCodegen.jl needs Julia 1.12 + codegen: [julia, juliacodegen] + exclude: + - version: '1.10' + codegen: juliacodegen + - version: '1.11' + codegen: juliacodegen include: # starting with Julia 1.10, we can enable opaque pointers # from Julia 1.12 on, this is the default. - version: '1.10' os: 'ubuntu-24.04' llvm_args: '--opaque-pointers' + codegen: julia - version: '1.10' os: 'ubuntu-24.04-arm' llvm_args: '--opaque-pointers' + codegen: julia - version: '1.10' os: 'macOS-15' llvm_args: '--opaque-pointers' + codegen: julia - version: '1.10' os: 'windows-2025' llvm_args: '--opaque-pointers' + codegen: julia - version: '1.11' os: 'ubuntu-24.04' llvm_args: '--opaque-pointers' + codegen: julia - version: '1.11' os: 'ubuntu-24.04-arm' llvm_args: '--opaque-pointers' + codegen: julia - version: '1.11' os: 'macOS-15' llvm_args: '--opaque-pointers' + codegen: julia - version: '1.11' os: 'windows-2025' llvm_args: '--opaque-pointers' + codegen: julia steps: - uses: actions/checkout@v7 @@ -60,19 +78,35 @@ jobs: - name: Set-up cache uses: julia-actions/cache@v3 + # TEMPORARY, until the LLVM.jl, JuliaIR.jl and JuliaCodegen.jl changes this branch + # needs are released: build LLVMExtra from the LLVM.jl branch, and set up the test + # environment with it and JuliaCodegen.jl (see .ci/juliacodegen.jl). Restore + # julia-runtest and the coverage steps after. + - uses: msys2/setup-msys2@v2 + if: runner.os == 'Windows' + with: + path-type: inherit + install: make mingw-w64-x86_64-gcc mingw-w64-x86_64-cmake + - name: Build LLVMExtra + if: runner.os != 'Windows' + run: julia --color=yes .ci/juliacodegen.jl build + - name: Build LLVMExtra (in msys2) + if: runner.os == 'Windows' + shell: msys2 {0} + run: julia --color=yes .ci/juliacodegen.jl build + - name: Set up the test environment + run: julia --color=yes .ci/juliacodegen.jl test ${{ matrix.codegen }} + - 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 }} + JULIACODEGEN_LEDGER: ${{ runner.temp }}/juliacodegen-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 JuliaCodegen.jl explicitly, so check the preference too + - name: Check that JuliaCodegen.jl was the default + if: matrix.codegen == 'juliacodegen' + run: julia --project=test -e 'using GPUCompiler; GPUCompiler.CODEGEN === :juliacodegen || error("codegen preference not applied")' + - name: Check that JuliaCodegen.jl ran + if: matrix.codegen == 'juliacodegen' + run: julia .ci/juliacodegen.jl check-ledger '${{ runner.temp }}/juliacodegen-ledger.tsv' diff --git a/.gitignore b/.gitignore index 229360307..5870834cc 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ test/Manifest.toml test/LocalPreferences.toml Manifest.toml Manifest-*.toml +LLVM.jl/ diff --git a/Project.toml b/Project.toml index a83168fdd..7b877b2e2 100644 --- a/Project.toml +++ b/Project.toml @@ -29,14 +29,19 @@ 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" +JuliaCodegen = "f68e5268-cafc-4116-b772-1c15436a0999" 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" SPIRV_LLVM_Translator_jll = "4a5d46fc-d8cf-5151-a261-86b458210efb" +[sources] +LLVM = {url = "https://github.com/JuliaLLVM/LLVM.jl", rev = "tb/inline-api"} + [extensions] CthulhuExt = "Cthulhu" HighlightsExt = "Highlights" +JuliaCodegenExt = "JuliaCodegen" [compat] AMDGPU_LLVM_Backend_jll = "23.1.3" @@ -45,6 +50,7 @@ Cthulhu = "2, 3" ExprTools = "0.1" Highlights = "0.6" InteractiveUtils = "1" +JuliaCodegen = "0.1" LLVM = "10" LLVMDowngrader_jll = "0.11, 0.12" Libdl = "1" diff --git a/README.md b/README.md index fc8988a23..ec630660b 100644 --- a/README.md +++ b/README.md @@ -51,3 +51,27 @@ 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. + + +## JuliaCodegen.jl (experimental) + +GPUCompiler lowers Julia IR to LLVM IR with Julia's own code generator. It can instead use +[JuliaCodegen.jl](https://github.com/JuliaLLVM/JuliaCodegen.jl), a code generator written +in Julia, on Julia 1.12 and later. This is selected per compilation with the `codegen` +keyword argument of `CompilerConfig` (`:julia` or `:juliacodegen`), whose default comes +from a preference: + +```julia +using Preferences, GPUCompiler +set_preferences!(GPUCompiler, "codegen" => "juliacodegen") +``` + +The preference takes effect after restarting Julia. While precompiling packages that do not +load JuliaCodegen.jl, configurations keep using Julia's code generator. + +JuliaCodegen.jl must be loaded. When the preference selects it, loading GPUCompiler also +loads JuliaCodegen.jl if the active environment provides it; otherwise, load it with +`using JuliaCodegen`. Code it does not support yet calls native code, which validation +reports on targets without Julia's runtime. + +To run the test suite with JuliaCodegen.jl, set the preference in `test/LocalPreferences.toml`. diff --git a/deps/build.jl b/deps/build.jl new file mode 100644 index 000000000..2968c391a --- /dev/null +++ b/deps/build.jl @@ -0,0 +1,12 @@ +# TEMPORARY, with .ci/juliacodegen.jl: the assert jobs run TestAsserts.yml from the default +# branch (it is triggered by `workflow_run`), which only runs julia-buildpkg and +# julia-runtest. Pkg.build runs this file, so set up the environments here the way +# Test.yml does, with LLVMExtra built from the LLVM.jl branch. Delete with +# .ci/juliacodegen.jl. +if get(ENV, "GITHUB_WORKFLOW", "") == "TestAsserts" + script = joinpath(dirname(@__DIR__), ".ci", "juliacodegen.jl") + # not in Pkg.build's sandbox environment + julia = addenv(Base.julia_cmd(), "JULIA_PROJECT" => nothing, "JULIA_LOAD_PATH" => nothing) + run(`$julia --color=yes $script build`) + run(`$julia --color=yes $script test $(VERSION >= v"1.12" ? "juliacodegen" : "julia")`) +end diff --git a/ext/JuliaCodegenExt.jl b/ext/JuliaCodegenExt.jl new file mode 100644 index 000000000..3697c339a --- /dev/null +++ b/ext/JuliaCodegenExt.jl @@ -0,0 +1,114 @@ +# JuliaCodegen.jl, a code generator written in Julia, for jobs that select it with +# `codegen=:juliacodegen`. Loading this extension alone does not change generated code. +module JuliaCodegenExt + +import GPUCompiler +using GPUCompiler: CompilerJob, llvm_triple, julia_datalayout, can_safepoint, + dwarf_version, llvm_debug_info +import JuliaCodegen +using LLVM + +const CC = Base.Compiler + +# The objects the generated code refers to, which GPUCompiler resolves to relocations from +# their addresses after code generation: kept alive for as long as the session, like the +# roots of Julia's own code generator. +const ROOTS = IdSet{Any}() +const ROOTS_LOCK = ReentrantLock() + +# with `JULIACODEGEN_LEDGER=`, every compilation appends a line to the file: the +# process, `juliacodegen`, the method instance, the number of functions, and the number +# of functions that call native code instead (fallbacks), tab-separated +function record(@nospecialize(job::CompilerJob), nfunctions, nfallbacks) + ledger = get(ENV, "JULIACODEGEN_LEDGER", "") + isempty(ledger) && return + open(ledger, "a") do io + println(io, join((getpid(), "juliacodegen", repr(job.source), nfunctions, nfallbacks), '\t')) + end +end + +# JuliaCodegen.jl's name of a `LLVM.DebugEmissionKind`: line tables, in a compile unit of +# that kind, but for `NoDebug` at debug level 0 (as Julia's code generator, which emits +# the locations unless Julia runs with `-g0`) +function debug_info(kind, level) + kind == LLVM.DebugEmissionKind.NoDebug && return level == 0 ? :none : :no_debug + kind == LLVM.DebugEmissionKind.LineTablesOnly && return :line_tables + kind == LLVM.DebugEmissionKind.DebugDirectivesOnly && return :directives + return :full +end + +# the configuration of JuliaCodegen.jl for `job`: its target, and the code generation +# parameters GPUCompiler gives Julia's code generator (see `compile_method_instance`) +function codegen_config(@nospecialize(job::CompilerJob)) + target = job.config.target + dl = julia_datalayout(target) + datalayout = dl === nothing ? nothing : (s = string(dl); LLVM.dispose(dl); s) + cpu = hasproperty(target, :cpu) ? String(target.cpu) : "" + features = hasproperty(target, :features) ? String(target.features) : "" + jctarget = JuliaCodegen.Target(; triple=llvm_triple(target), cpu, features, datalayout) + # unsupported constructs call native code, which validation reports where the target + # can't (with `JULIACODEGEN_UNSUPPORTED=error`, they throw, which says what they are); + # errors of the code generator itself are reported as such + on_unsupported = get(ENV, "JULIACODEGEN_UNSUPPORTED", "") == "error" ? :error : :fallback + debug = debug_info(llvm_debug_info(job), job.config.debug_level) + return JuliaCodegen.CodegenConfig(; target=jctarget, gcstack_arg=false, + safepoint_on_entry=can_safepoint(job), debug_info=debug, + on_unsupported, on_internal_error=:error) +end + +# The entry of the job in the specsig convention as Julia's code generator names it, +# `julia_f_N` (back ends and tests refer to it): JuliaCodegen.jl names its function so, +# and the entry `j_f_N`, which inlines it. Swap the names. +function name_entry!(mod::LLVM.Module, functions, root_ci) + fs = get(functions, root_ci, nothing) + (fs === nothing || fs.specfunc === nothing) && return + base = chopprefix(fs.specfunc, "j_") + body = get(LLVM.functions(mod), "julia_" * base, nothing) + entry = LLVM.functions(mod)[fs.specfunc] + body === nothing || LLVM.name!(body, "jlbody_" * base) + LLVM.name!(entry, "julia_" * base) + functions[root_ci] = JuliaCodegen.CompiledFunctions(fs.func, "julia_" * base) + return +end + +# `compile_method_instance`'s code generation with JuliaCodegen.jl, from the sources of the +# `(CodeInstance, CodeInfo)` pairs inference produced: the module, the functions of each +# method instance, and the objects its global variables refer to +function emit(@nospecialize(job::CompilerJob), codeinfos, root_ci) + codeinfos === nothing && error("codegen=:juliacodegen requires the sources from inference") + config = codegen_config(job) + mod = LLVM.Module("start") + mod.flags["Dwarf Version", LLVM.ModuleFlagBehavior.Warning] = + LLVM.Metadata(LLVM.ConstantInt(dwarf_version(job.config.target))) + mod.flags["Debug Info Version", LLVM.ModuleFlagBehavior.Warning] = + LLVM.Metadata(LLVM.ConstantInt(LLVM.DEBUG_METADATA_VERSION())) + functions, output = JuliaCodegen.emit_into!(mod, codeinfos; config, world=job.world) + root_ci === nothing || name_entry!(mod, functions, root_ci) + + compiled = Dict() + for (ci, src) in codeinfos + mi = CC.get_ci_mi(ci) + fs = functions[ci] + compiled[mi] = (; ci, func=fs.func, specfunc=fs.specfunc, + src=job.config.validate ? src : nothing) + end + if !haskey(compiled, job.source) && root_ci !== nothing && haskey(compiled, CC.get_ci_mi(root_ci)) + # the root can be less specific than `job.source` (see `compile_method_instance`) + compiled[job.source] = compiled[CC.get_ci_mi(root_ci)] + end + @assert haskey(compiled, job.source) + + gv_to_value = Dict{String,Ptr{Cvoid}}() + @lock ROOTS_LOCK for (name, x) in output.literals + push!(ROOTS, x) + gv_to_value[name] = ccall(:jl_value_ptr, Ptr{Cvoid}, (Any,), x) + end + record(job, length(functions), length(output.fallbacks)) + return mod, compiled, gv_to_value +end + +function __init__() + GPUCompiler.codegen_provider[] = GPUCompiler.CodegenProvider(emit) +end + +end # module JuliaCodegenExt diff --git a/src/GPUCompiler.jl b/src/GPUCompiler.jl index 8231c94b7..ba2bf8110 100644 --- a/src/GPUCompiler.jl +++ b/src/GPUCompiler.jl @@ -74,6 +74,7 @@ include("runtime.jl") # compiler implementation include("deprecated.jl") +include("codegen_provider.jl") include("jlgen.jl") include("irgen.jl") include("determinism.jl") @@ -104,6 +105,9 @@ function __init__() Tracy.@register_tracepoints() end register_deferred_codegen() + + # last, as it loads other packages + load_codegen() end end # module diff --git a/src/codegen_provider.jl b/src/codegen_provider.jl new file mode 100644 index 000000000..9fb59e803 --- /dev/null +++ b/src/codegen_provider.jl @@ -0,0 +1,60 @@ +# The code generator that lowers Julia IR to LLVM IR: Julia's own (`jl_emit_native`), or +# JuliaCodegen.jl, a code generator written in Julia + +# The default of `CompilerConfig`'s `codegen`. Set with +# `Preferences.set_preferences!(GPUCompiler, "codegen" => "juliacodegen")`. +const CODEGEN = Symbol(@load_preference("codegen", "julia")) +CODEGEN in (:julia, :juliacodegen) || + error("Invalid codegen preference $(repr(String(CODEGEN))); expected \"julia\" or \"juliacodegen\"") + +# JuliaCodegen.jl's code generator, supplied by the JuliaCodegenExt extension: a function +# with the contract of `compile_method_instance`'s code generation, from the job and the +# `(CodeInstance, CodeInfo)` pairs inference produced to the LLVM module, the functions of +# each method instance, and the values of the module's global variables. +struct CodegenProvider + emit::Function +end +const codegen_provider = Ref{Union{Nothing,CodegenProvider}}(nothing) + +# The default of `CompilerConfig`'s `codegen`: the preference, except while precompiling +# without JuliaCodegen.jl loaded, e.g., in the workload of a package that does not depend +# on it. That uses Julia's code generator; since the setting is part of the configuration, +# what it caches is not used for configurations that select JuliaCodegen.jl. +function default_codegen() + if CODEGEN === :juliacodegen && codegen_provider[] === nothing && + ccall(:jl_generating_output, Cint, ()) == 1 + return :julia + end + return CODEGEN +end + +const JULIACODEGEN = Base.PkgId(Base.UUID("f68e5268-cafc-4116-b772-1c15436a0999"), "JuliaCodegen") + +# When the preference selects JuliaCodegen.jl, load it if the environment provides it, so +# that code using GPUCompiler through other packages (e.g. their test suites) gets it +# without loading it itself. Its extension then registers the code generator, once +# GPUCompiler has finished loading. +function load_codegen() + CODEGEN === :juliacodegen || return + ccall(:jl_generating_output, Cint, ()) == 1 && return + haskey(Base.loaded_modules, JULIACODEGEN) && return + Base.locate_package(JULIACODEGEN) === nothing && return + Base.require(JULIACODEGEN) + return +end + +function check_codegen(@nospecialize(job::CompilerJob)) + job.config.codegen === :juliacodegen || return + VERSION >= v"1.12" || error("codegen=:juliacodegen requires Julia 1.12 or later") + codegen_provider[] === nothing && + error("codegen=:juliacodegen requires JuliaCodegen.jl to be loaded (`using JuliaCodegen`)") + return +end + +# generate the LLVM IR of the `(CodeInstance, CodeInfo)` pairs `codeinfos` with +# JuliaCodegen.jl, see `compile_method_instance` +function emit_juliacodegen(@nospecialize(job::CompilerJob), codeinfos, root_ci) + check_codegen(job) + provider = codegen_provider[]::CodegenProvider + return Base.invokelatest(provider.emit, job, codeinfos, root_ci) +end diff --git a/src/driver.jl b/src/driver.jl index 8723d528d..ff2827958 100644 --- a/src/driver.jl +++ b/src/driver.jl @@ -104,6 +104,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_codegen(job) @tracepoint "Validation" begin check_method(job) # not optional diff --git a/src/interface.jl b/src/interface.jl index 1b656eaf0..2d67402ed 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, + :codegen] """ CompilerConfig(target, params; kernel=true, entry_abi=:specfunc, name=nothing, @@ -177,6 +178,10 @@ 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) +- `codegen`: the code generator that lowers Julia IR to LLVM IR: `:julia` for Julia's own, + or `:juliacodegen` for JuliaCodegen.jl (experimental, Julia 1.12 and later), which must + then be loaded. The default comes from the `codegen` preference of GPUCompiler + (`:julia` if unset). """ struct CompilerConfig{T,P} target::T @@ -193,6 +198,7 @@ struct CompilerConfig{T,P} cleanup::Bool validate::Bool strip::Bool + codegen::Symbol # internal toplevel::Bool @@ -206,10 +212,13 @@ 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, codegen=default_codegen(), only_entry=false) if entry_abi ∉ (:specfunc, :func) error("Unknown entry_abi=$entry_abi") end + if codegen ∉ (:julia, :juliacodegen) + error("Unknown codegen=$codegen") + end owner = @static if HAS_INTEGRATED_CACHE cache_owner(target, params, always_inline) else @@ -217,8 +226,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, codegen, + toplevel, only_entry, owner) end end @@ -228,8 +237,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, - only_entry=cfg.only_entry) + validate=cfg.validate, strip=cfg.strip, codegen=cfg.codegen, + 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? # see how PkgEval does that. @@ -240,8 +249,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, codegen, + toplevel, only_entry) end function Base.show(io::IO, @nospecialize(cfg::CompilerConfig{T})) where {T} @@ -263,6 +272,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.codegen, 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/jlgen.jl b/src/jlgen.jl index a9278c075..fe92727bd 100644 --- a/src/jlgen.jl +++ b/src/jlgen.jl @@ -690,6 +690,11 @@ function compile_method_instance(@nospecialize(job::CompilerJob)) end end + # JuliaCodegen.jl generates the code from the same sources (see codegen_provider.jl) + if job.config.codegen === :juliacodegen + return emit_juliacodegen(job, codeinfo_pairs, root_ci) + end + # create a callback to look-up function in our cache, # and keep track of the method instances we needed. method_instances = [] diff --git a/test/juliacodegen.jl b/test/juliacodegen.jl new file mode 100644 index 000000000..b214dc517 --- /dev/null +++ b/test/juliacodegen.jl @@ -0,0 +1,219 @@ +@testset "configuration" begin + job, _ = PTX.create_job(identity, (Int,)) + @test job.config.codegen === GPUCompiler.CODEGEN + + julia, _ = PTX.create_job(identity, (Int,); codegen=:julia) + jc, _ = PTX.create_job(identity, (Int,); codegen=:juliacodegen) + @test julia.config.codegen === :julia + @test jc.config.codegen === :juliacodegen + # part of the identity of cached artifacts + @test julia.config != jc.config + @test hash(julia.config) != hash(jc.config) + # preserved by derived configs, e.g., of nested jobs + @test CompilerConfig(jc.config; toplevel=false).codegen === :juliacodegen + + @test_throws "Unknown codegen=llvm" PTX.create_job(identity, (Int,); codegen=:llvm) +end + +@testset "requirements" begin + kernel() = nothing + job, _ = PTX.create_job(kernel, (); codegen=:juliacodegen) + + provider = GPUCompiler.codegen_provider[] + try + GPUCompiler.codegen_provider[] = nothing + JuliaContext() do ctx + @test_throws "requires JuliaCodegen.jl to be loaded" GPUCompiler.compile(:llvm, job) + end + finally + GPUCompiler.codegen_provider[] = provider + end +end + +@testset "loading" begin + # with the preference selecting JuliaCodegen.jl, loading GPUCompiler loads it from the + # environment, and the extension registers the code generator + code = "import GPUCompiler; print(GPUCompiler.codegen_provider[] !== nothing)" + out = read(`$(Base.julia_cmd()) --startup-file=no --project=$(Base.active_project()) -e $code`, String) + @test out == string(GPUCompiler.CODEGEN === :juliacodegen) +end + +# Packages that do not depend on JuliaCodegen.jl may compile code while precompiling, e.g., +# in their workload, in a process without it. +precompile_test_harness("precompilation without JuliaCodegen.jl") do load_path + create_standalone(load_path, "JuliaCodegenCompiler", "native.jl") + write(joinpath(load_path, "JuliaCodegenWorkload.jl"), :( + module JuliaCodegenWorkload + import JuliaCodegenCompiler + using JuliaCodegenCompiler.GPUCompiler + + kernel(x) = x + 1 + const codegen = let + job, _ = JuliaCodegenCompiler.Native.create_job(kernel, (Int,)) + JuliaContext() do ctx + GPUCompiler.compile(:llvm, job) + end + job.config.codegen + end + end) |> string) + Base.compilecache(Base.PkgId("JuliaCodegenWorkload")) + + # such compilations use Julia's code generator; that is part of their configuration, + # so results cached for them are not used for configurations with JuliaCodegen.jl + mod = Base.require(Base.PkgId("JuliaCodegenWorkload")) + @test Base.invokelatest(getglobal, mod, :codegen) === :julia +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)), + ] + + # arguments to run the kernels with on the host: a buffer for the first argument + # (whose contents are the result), then the others + const ARGS = Dict( + alloc => (Int, 4, (7,)), ref => (Int, 1, (21,)), simd => (Float32, 8, (8,)), + f16 => (Float16, 1, (Float16(1.5),)), throws => (Int, 1, (3,)), + i128 => (Int128, 1, (Int128(1) << 40,)), tuple => (Float32, 1, ((1f0, 2f0, 3f0, 4f0),)), + aggregate => (Pair2, 1, (Pair2(1.5f0, Int128(-3)),)), loop => (Int, 4, (4,)), + closure => (Int, 1, (41,)), complex => (ComplexF32, 1, (ComplexF32(1, 2),)), + math => (Float64, 1, (-2.25,)), div => (Int, 1, (17, 5)), + ) +end + +# The function signature of a kernel's entry after GPUCompiler's processing, with the IR +# of the generator `codegen`. Without validation, which rejects some of the corpus on some +# targets (e.g. `Float64` on Metal) after optimization. +function entry_with(codegen, backend, f, tt; kwargs...) + JuliaContext() do ctx + job, _ = backend.create_job(f, tt; codegen, validate=false, kwargs...) + ir, meta = GPUCompiler.compile(:llvm, job) + sig = string(LLVM.function_type(meta.entry)) + cc = LLVM.callconv(meta.entry) + LLVM.dispose(ir) + (sig, cc) + end +end + +describe(kwargs) = join(("$k=$v" for (k, v) in pairs(kwargs)), " ") + +function corpus_cases() + cases = Any[] + backends = Any[("PTX", PTX, (;)), ("SPIR-V", SPIRV, (; backend=:llvm)), + ("Metal", Metal, (;)), ("native", Native, (;)), ("BPF", BPF, (;))] + # see runtests.jl + 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 "the same entries" begin + # JuliaCodegen.jl generates functions in Julia's specsig convention, which GPUCompiler + # then rewrites the same way for every back end + @testset "$name $(nameof(f)) $(describe(kwargs))" for (name, backend, f, tt, kwargs) in corpus_cases() + @test entry_with(:juliacodegen, backend, f, tt; kwargs...) == + entry_with(:julia, backend, f, tt; kwargs...) + end +end + +# The kernels of the corpus with host pointers, compiled with the generator `codegen`, +# loaded into a JIT and run on a buffer, whose contents are returned. +function run_with(codegen, f, tt) + T, n, args = Corpus.ARGS[f] + job, _ = Native.create_job(f, tt; codegen, entry_abi=:func, relocations=:patch, + validate=false) + JuliaContext() do ctx + obj, meta = GPUCompiler.compile(:obj, job) + fptr, lljit, _table = Native.load(Vector{UInt8}(codeunits(obj)), meta.entry.name, + meta.relocations) + @dispose lljit=lljit begin + buf = T <: Number ? zeros(T, n) : fill(T(map(zero, fieldtypes(T))...), n) + f === Corpus.simd && (buf .= 1:n) + boxed = Any[pointer(buf), args...] + GC.@preserve buf boxed ccall(fptr, Any, (Any, Ptr{Any}, Int32), f, + pointer(boxed), length(boxed)) + buf + end + end +end + +@testset "the same results" begin + @testset "$(nameof(f))" for (f, tt) in Corpus.KERNELS + @test run_with(:juliacodegen, f, tt) == run_with(:julia, f, tt) + end +end + +@testset "JuliaCodegen.jl runs" begin + # the extension records every compilation in its ledger + function compilations(codegen, f, tt) + ledger = tempname() + withenv("JULIACODEGEN_LEDGER" => ledger) do + entry_with(codegen, PTX, f, tt; kernel=true) + end + isfile(ledger) || return 0 + return countlines(ledger) + end + @test compilations(:julia, Corpus.simd, (Ptr{Float32}, Int)) == 0 + @test compilations(:juliacodegen, Corpus.simd, (Ptr{Float32}, Int)) == 1 +end diff --git a/test/native/precompile.jl b/test/native/precompile.jl index 042ed1ccb..3a90d45fb 100644 --- a/test/native/precompile.jl +++ b/test/native/precompile.jl @@ -114,9 +114,12 @@ precompile_test_harness("Inference caching") do load_path NativeBackend.persistent_kernel, (Int,); relocations=:patch) persistent_res = GPUCompiler.cached_results(NativeBackend.Results, persistent_job) @test persistent_res !== nothing - if GPUCompiler.supports_relocatable_ir() + if GPUCompiler.supports_relocatable_ir() && GPUCompiler.CODEGEN === :julia @test persistent_res.artifact == "persistent" else + # (with the preference selecting JuliaCodegen.jl, which NativeBackend does not + # depend on, the precompiled job used Julia's code generator: another + # configuration, see `default_codegen`) @test persistent_res.artifact === nothing end diff --git a/test/runtests.jl b/test/runtests.jl index 597e12794..39b5fcab0 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -1,12 +1,17 @@ using ParallelTestRunner import GPUCompiler, LLVM using GPUCompiler, LLVM +# JuliaCodegen.jl (Julia 1.12 and later), when the environment provides it: loading it +# changes nothing unless it is selected (with the `codegen` preference, or per test) +const HAVE_JULIACODEGEN = VERSION >= v"1.12" && Base.find_package("JuliaCodegen") !== nothing +HAVE_JULIACODEGEN && @eval using JuliaCodegen 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 + $(HAVE_JULIACODEGEN ? :(using JuliaCodegen) : nothing) using SPIRV_LLVM_Backend_jll, SPIRV_LLVM_Translator_jll, SPIRV_Tools_jll using LLVMDowngrader_jll using NVPTX_LLVM_Backend_jll @@ -35,6 +40,10 @@ if filter_tests!(testsuite, args) delete!(testsuite, key) end + if !HAVE_JULIACODEGEN + @warn "JuliaCodegen.jl not available; skipping its tests" + delete!(testsuite, "juliacodegen") + end if LLVM.is_asserts() @warn "LLVM with assertions; skipping GCN tests" delete!(testsuite, "gcn")