From f566d1c4c127b0233ab446bc74a7731463b6f5e1 Mon Sep 17 00:00:00 2001 From: Valentin Churavy Date: Mon, 19 Oct 2026 19:55:13 +0200 Subject: [PATCH 1/2] Generate code with JuliaCodegen.jl, as an opt-in `CompilerConfig` gets a `codegen` field: `:julia` for Julia's code generator, as today, or `:juliacodegen` for JuliaCodegen.jl, a code generator written in Julia (Julia 1.12 and later). Its default comes from the `codegen` preference. It is part of the configuration, so cached artifacts are keyed on it; inference caches are not. JuliaCodegen.jl is a weak dependency. Its extension registers a code generator with the contract of `compile_method_instance`'s code generation: from the `(CodeInstance, CodeInfo)` pairs inference produced to the module, the functions of each method instance, and the objects of its global variables. It compiles them with `JuliaCodegen.emit_into!`, using the job's target, data layout and debug info, no pgcstack argument, and the job's safepoints. The job's entry gets the name native code gives it, and unsupported constructs call native code (which validation reports on targets without Julia's runtime). Loading the extension alone changes nothing, and selecting `:juliacodegen` without it is an error before compilation starts. When the preference selects it, loading GPUCompiler also loads JuliaCodegen.jl if the environment provides it. Precompilation workloads of packages that don't load JuliaCodegen.jl keep using Julia's code generator. Tests (`test/juliacodegen.jl`, run where JuliaCodegen.jl is available): the configuration, the requirements, loading through the preference, a precompilation workload, that JuliaCodegen.jl ran (its ledger), and a corpus of kernels: - for PTX, GCN, SPIR-V, Metal, native and BPF, the entry has the same signature and calling convention with either code generator; - on the native target, the kernels give the same results. Assisted-by: Claude Code (Opus 5.5) --- Project.toml | 3 + README.md | 24 +++++ ext/JuliaCodegenExt.jl | 114 ++++++++++++++++++++ src/GPUCompiler.jl | 4 + src/codegen_provider.jl | 60 +++++++++++ src/driver.jl | 1 + src/interface.jl | 26 +++-- src/jlgen.jl | 5 + test/juliacodegen.jl | 219 ++++++++++++++++++++++++++++++++++++++ test/native/precompile.jl | 5 +- test/runtests.jl | 9 ++ 11 files changed, 461 insertions(+), 9 deletions(-) create mode 100644 ext/JuliaCodegenExt.jl create mode 100644 src/codegen_provider.jl create mode 100644 test/juliacodegen.jl diff --git a/Project.toml b/Project.toml index a83168fdd..db6213adc 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" +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" @@ -37,6 +38,7 @@ SPIRV_LLVM_Translator_jll = "4a5d46fc-d8cf-5151-a261-86b458210efb" [extensions] CthulhuExt = "Cthulhu" HighlightsExt = "Highlights" +JuliaCodegenExt = "JuliaCodegen" [compat] AMDGPU_LLVM_Backend_jll = "23.1.3" @@ -45,6 +47,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/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") From 3e423ef82132c3e3f329c31e7fa6938cc2ee0b0d Mon Sep 17 00:00:00 2001 From: Valentin Churavy Date: Mon, 19 Oct 2026 19:55:20 +0200 Subject: [PATCH 2/2] [temp] Take LLVM.jl and JuliaCodegen.jl from branches, build LLVMExtra in CI JuliaCodegen.jl, JuliaIR.jl and the LLVM.jl changes they need are not released yet: - LLVM.jl comes from `[sources]`; - JuliaCodegen.jl and JuliaIR.jl come from the branches in `.ci/juliacodegen.jl`, added to the test environment on Julia 1.12 and later only, as JuliaCodegen.jl needs 1.12. CI builds LLVMExtra from the LLVM.jl branch and gets a `codegen` axis: `julia` everywhere, `juliacodegen` from Julia 1.12 on. The `juliacodegen` jobs check that the preference was applied, and that JuliaCodegen.jl compiled code (its ledger). `deps/build.jl` does the same for the assert jobs. To be dropped once the packages are released. Assisted-by: Claude Code (Opus 5.5) --- .ci/juliacodegen.jl | 178 +++++++++++++++++++++++++++++++++++++ .github/workflows/Test.yml | 62 ++++++++++--- .gitignore | 1 + Project.toml | 3 + deps/build.jl | 12 +++ 5 files changed, 242 insertions(+), 14 deletions(-) create mode 100644 .ci/juliacodegen.jl create mode 100644 deps/build.jl 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 db6213adc..7b877b2e2 100644 --- a/Project.toml +++ b/Project.toml @@ -35,6 +35,9 @@ 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" 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