Skip to content

Commit 5d15538

Browse files
vchuravyclaude
andcommitted
Switch POCL backend to CompilerCaching.jl
Adopts the GPUCompiler 2.0 caching API (JuliaGPU/GPUCompiler.jl#794): compilation results are stored via `GPUCompiler.cached_results` on the CodeInstance (Julia's integrated code cache on 1.11+, persisting through precompilation) instead of the legacy per-context `cached_compilation` dictionaries. Follows the same approach as JuliaGPU/OpenCL.jl#431. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 0534c73 commit 5d15538

5 files changed

Lines changed: 132 additions & 41 deletions

File tree

Project.toml

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ StaticArrays = "90137ffa-7385-5640-81b9-e52037218182"
2222
UUIDs = "cf7118a7-6976-5b1a-9a39-7adc72f591a4"
2323
pocl_standalone_jll = "54f56a70-6062-5590-a942-1226658f6c83"
2424

25+
[sources]
26+
GPUCompiler = {url = "https://github.com/JuliaGPU/GPUCompiler.jl", rev = "tb/compilercaching"}
27+
2528
[weakdeps]
2629
EnzymeCore = "f151be2c-9106-41f4-ab19-57ee4f262869"
2730
LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e"
@@ -36,9 +39,9 @@ SparseArraysExt = "SparseArrays"
3639
Adapt = "0.4, 1.0, 2.0, 3.0, 4"
3740
Atomix = "0.1, 1"
3841
EnzymeCore = "0.7, 0.8.1"
39-
GPUCompiler = "1.13.3"
42+
GPUCompiler = "2"
4043
InteractiveUtils = "1.6"
41-
LLVM = "9.4.1"
44+
LLVM = "9.9"
4245
LinearAlgebra = "1.6"
4346
MacroTools = "0.5"
4447
PrecompileTools = "1"

src/pocl/compiler/compilation.jl

Lines changed: 38 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,32 @@ end
88
const OpenCLCompilerConfig = CompilerConfig{SPIRVCompilerTarget, OpenCLCompilerParams}
99
const OpenCLCompilerJob = CompilerJob{SPIRVCompilerTarget, OpenCLCompilerParams}
1010

11+
"""
12+
OpenCLResults
13+
14+
Cached compilation results for an OpenCL kernel job, managed by
15+
`GPUCompiler.cached_results`. Fields are populated through the compile pipeline:
16+
`obj` (SPIR-V bytes) + `entry` + `device_rng` after codegen, and `kernels` after the
17+
session-local link onto an OpenCL context. The first three are session-portable
18+
(cached through precompilation, except when GPUCompiler marks the job
19+
session-dependent and wipes its entries before image serialization); `kernels` is
20+
session-local and never populated during precompilation. `obj === nothing`
21+
identifies a job that has not been compiled yet.
22+
23+
`kernels` is a small linear cache of `(cl.Context, cl.Kernel)` pairs. The cache partition
24+
already covers everything that affects codegen via `GPUCompiler.cache_owner`, so the only
25+
runtime-visible dimension left is the OpenCL context that owns the linked `cl.Kernel`.
26+
A linear scan with `===` is fastest in the common case (n=1) and stays cheap for the
27+
rare workload that bounces between a handful of contexts on the same device.
28+
"""
29+
mutable struct OpenCLResults
30+
obj::Union{Nothing, Vector{UInt8}} # SPIR-V binary
31+
entry::Union{Nothing, String}
32+
device_rng::Bool
33+
kernels::Vector{Tuple{cl.Context, cl.Kernel}} # session-local; linear-scanned
34+
OpenCLResults() = new(nothing, nothing, false, Tuple{cl.Context, cl.Kernel}[])
35+
end
36+
1137
GPUCompiler.runtime_module(::CompilerJob{<:Any, OpenCLCompilerParams}) = POCL
1238

1339
GPUCompiler.method_table_view(job::OpenCLCompilerJob) = GPUCompiler.StackedMethodTable(job.world, method_table, SPIRVIntrinsics.method_table)
@@ -114,18 +140,7 @@ function GPUCompiler.finish_linked_module!(@nospecialize(job::OpenCLCompilerJob)
114140
end
115141

116142

117-
## compiler implementation (cache, configure, compile, and link)
118-
119-
# cache of compilation caches, per context
120-
const _compiler_caches = Dict{cl.Context, Dict{Any, Any}}()
121-
function compiler_cache(ctx::cl.Context)
122-
cache = get(_compiler_caches, ctx, nothing)
123-
if cache === nothing
124-
cache = Dict{Any, Any}()
125-
_compiler_caches[ctx] = cache
126-
end
127-
return cache
128-
end
143+
## compiler implementation (configure, compile, and link)
129144

130145
# cache of compiler configurations, per device (but additionally configurable via kwargs)
131146
const _toolchain = Ref{Any}()
@@ -153,10 +168,13 @@ end
153168
return CompilerConfig(target, params; kernel, name, always_inline)
154169
end
155170

156-
# compile to executable machine code
157-
function compile(@nospecialize(job::CompilerJob))
158-
# TODO: this creates a context; cache those.
159-
return obj, meta = JuliaContext() do ctx
171+
# run inference + LLVM codegen + SPIR-V emission. returns `(obj, entry, device_rng)`,
172+
# all session-portable so they survive precompilation when stored on a cached `CodeInstance`.
173+
const compilations = Threads.Atomic{Int}(0)
174+
function compile_to_obj(@nospecialize(job::CompilerJob))
175+
compilations[] += 1
176+
177+
return JuliaContext() do ctx
160178
obj, meta = GPUCompiler.compile(:obj, job)
161179

162180
entry = LLVM.name(meta.entry)
@@ -166,13 +184,13 @@ function compile(@nospecialize(job::CompilerJob))
166184
end
167185
end
168186

169-
# link into an executable kernel
170-
function link(@nospecialize(job::CompilerJob), compiled)
187+
# link the SPIR-V bytes into a session-local `cl.Kernel` on the active context.
188+
function link_kernel(@nospecialize(job::CompilerJob), obj::Vector{UInt8}, entry::String)
171189
prog = if "cl_khr_il_program" in device().extensions
172-
cl.Program(compiled.obj, context())
190+
cl.Program(obj, context())
173191
else
174192
error("Your device does not support SPIR-V, which is currently required for native execution.")
175193
end
176194
cl.build!(prog)
177-
return (; kernel = cl.Kernel(prog, compiled.entry), compiled.device_rng)
195+
return cl.Kernel(prog, entry)
178196
end

src/pocl/compiler/execution.jl

Lines changed: 45 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -195,27 +195,56 @@ end
195195
const clfunction_lock = ReentrantLock()
196196

197197
function clfunction(f::F, tt::TT = Tuple{}; kwargs...) where {F, TT}
198-
ctx = context()
199-
dev = device()
200-
201198
Base.@lock clfunction_lock begin
202-
# compile the function
203-
cache = compiler_cache(ctx)
199+
config = compiler_config(device(); kwargs...)::OpenCLCompilerConfig
204200
source = methodinstance(F, tt)
205-
config = compiler_config(dev; kwargs...)::OpenCLCompilerConfig
206-
linked = GPUCompiler.cached_compilation(cache, source, config, compile, link)
207-
208-
# create a callable object that captures the function instance. we don't need to think
209-
# about world age here, as GPUCompiler already does and will return a different object
210-
h = hash(linked.kernel, hash(f, hash(tt)))
211-
kernel = get(_kernel_instances, h, nothing)
201+
job = CompilerJob(source, config)
202+
203+
res = compile_or_lookup(job)::OpenCLResults
204+
205+
# Resolve the cl.Kernel for the active context. Linear scan over the
206+
# session-local cache; almost always n=1, so this is one `===` compare.
207+
ctx = context()
208+
kernel = nothing
209+
@inbounds for (cached_ctx, cached_kernel) in res.kernels
210+
if cached_ctx === ctx
211+
kernel = cached_kernel
212+
break
213+
end
214+
end
212215
if kernel === nothing
213-
# create the kernel state object
214-
kernel = HostKernel{F, tt}(f, linked.kernel, linked.device_rng)
215-
_kernel_instances[h] = kernel
216+
kernel = link_kernel(job, res.obj::Vector{UInt8}, res.entry::String)
217+
# Don't cache session-local kernel handles while precompiling: the
218+
# results struct is serialized into the package image along with its
219+
# CodeInstance, and the handles would come back dangling.
220+
if ccall(:jl_generating_output, Cint, ()) != 1
221+
push!(res.kernels, (ctx, kernel))
222+
end
216223
end
217-
return kernel::HostKernel{F, tt}
224+
225+
h = hash(kernel, hash(f, hash(tt)))
226+
return get!(_kernel_instances, h) do
227+
HostKernel{F, tt}(f, kernel, res.device_rng)
228+
end::HostKernel{F, tt}
229+
end
230+
end
231+
232+
# Look up cached compile artifacts for `job`, compiling on miss. Storage is managed
233+
# by `GPUCompiler.cached_results` (Julia's integrated code cache on 1.11+, which also
234+
# persists artifacts through precompilation; a session-local store on 1.10).
235+
#
236+
# `obj === nothing` identifies an `OpenCLResults` that hasn't been compiled yet. The
237+
# `compile_hook` check additionally forces the compile path so reflection-style
238+
# consumers (`@device_code_*`) observe the compilation even on a cache hit.
239+
function compile_or_lookup(@nospecialize(job::CompilerJob))::OpenCLResults
240+
res = GPUCompiler.cached_results(OpenCLResults, job)
241+
if res.obj === nothing || GPUCompiler.compile_hook[] !== nothing
242+
compiled = compile_to_obj(job)
243+
res.obj = compiled.obj
244+
res.entry = compiled.entry
245+
res.device_rng = compiled.device_rng
218246
end
247+
return res
219248
end
220249

221250
# cache of kernel instances

src/pocl/device/runtime.jl

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,3 @@
1-
# reset the runtime cache from global scope, so that any change triggers recompilation
2-
GPUCompiler.reset_runtime()
3-
41
signal_exception() = return
52

63
malloc(sz) = C_NULL

test/runtests.jl

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,50 @@ end
1111
KernelAbstractions.versioninfo(POCLBackend())
1212
@info "Configuration" pocl = KernelAbstractions.POCL.nanoOpenCL.pocl_standalone_jll.libpocl
1313

14+
import KernelAbstractions.POCL: POCL, @opencl, @device_code_llvm
15+
16+
@testset "POCL compilation cache" begin
17+
mod = @eval module $(gensym())
18+
@noinline child() = return
19+
kernel() = child()
20+
end
21+
22+
count() = POCL.compilations[]
23+
launch() = @opencl mod.kernel()
24+
25+
# the initial launch compiles
26+
n = count()
27+
Base.invokelatest(launch)
28+
@test count() == n + 1
29+
30+
# a second launch hits the cache
31+
Base.invokelatest(launch)
32+
@test count() == n + 1
33+
34+
# jobs differing only in codegen-level settings get their own artifacts...
35+
POCL.clfunction(mod.kernel, Tuple{}; name = "custom")
36+
@test count() == n + 2
37+
# ... which are cached as well
38+
POCL.clfunction(mod.kernel, Tuple{}; name = "custom")
39+
@test count() == n + 2
40+
41+
# reflection observes already-compiled kernels (by forcing recompilation,
42+
# which must leave the cached entry in a usable state)
43+
@test !isempty(sprint(io -> (@device_code_llvm io = io Base.invokelatest(launch))))
44+
n = count()
45+
Base.invokelatest(launch)
46+
@test count() == n
47+
48+
# redefining the kernel recompiles...
49+
@eval mod kernel() = (child(); child())
50+
Base.invokelatest(launch)
51+
@test count() == n + 1
52+
# ... as does redefining a callee
53+
@eval mod @noinline child() = nothing
54+
Base.invokelatest(launch)
55+
@test count() == n + 2
56+
end
57+
1458
@testset "CPU back-end" begin
1559
struct CPUBackendArray{T, N, A} end # Fake and unused
1660
Testsuite.testsuite(CPU, "CPU", Base, Array, CPUBackendArray)

0 commit comments

Comments
 (0)