Repository navigation
Launching a kernel with a new signature compiles a lot of signature-independent host code #550
Description
Launching a kernel with a new argument signature compiles a lot of host code that doesn't depend on that signature. With pocl on an M1, launching an already-compiled kernel for a new element type costs ~75 ms (@opencl) to ~120 ms (KernelAbstractions) of host-side compilation, excluding GPUCompiler and the program build. JuliaGPU/Metal.jl#1014 fixed the same problems in Metal.jl (4-10x less host compilation per signature); this lists which of them also apply here.
Median host compile time per new signature, Julia 1.13.1 / 1.12.7, best of 3 runs. "Prototype" is the changes below, applied with @eval on top of 499e7f4:
| 1.13 KA | 1.13 @opencl |
1.12 KA | 1.12 @opencl |
|
|---|---|---|---|---|
| main | 117 ms | 77 ms | 121 ms | 74 ms |
| prototype | 66 ms | 31 ms | 79 ms | 29 ms |
The KA numbers include converting arguments of the new element type, which is compiled once per type rather than per kernel.
Suggested changes, largest effect first (line numbers at 0e4812c):
clfunction(src/compiler/execution.jl:244-274) is compiled per kernel, but onlymethodinstance(F, tt)and theHostKernelconstructor depend onF/TT. The lock, config,compile_or_lookupandlink_kernelcan move into a@noinlinefunction taking theMethodInstanceand the keyword arguments. In addition, Julia doesn't specialize onsource=fbecause it's a function-typed argument that is only passed through, so the keyword body is compiled twice per kernel andHostKernel{F,typeof(source),tt}(...)(:272) is a dynamic call;source::S=f ... where Savoids that. This alone saves ~20 ms per signature.- The KernelAbstractions launch
(obj::KA.Kernel{OpenCLBackend})(args...; ...)(src/OpenCLKernels.jl:132) is compiled forVararg{Any}, by the same heuristic, so every call in it (@opencl launch=false, the launch itself, ...) is dispatched at run time and compiled separately.args::Vararg{Any,N} ... where Nfixes that. The parts that don't depend on the arguments (launch_config,mkcontext, workgroup size tuning) can go into@noinlinefunctions as well. - The launch chain: the generated
AbstractKernelcall (src/compiler/execution.jl:182-226) →launch_with_exception_mailbox(src/compiler/exceptions.jl:176-199) →cl.call(lib/cl/kernel.jl:263-328) →set_args!(lib/cl/kernel.jl:418), each a keyword call fully specialized on the arguments, while most of their bodies don't depend on them:- the mailbox lookup and
KernelState/launch ID bookkeeping can be@noinlinefunctions that don't take the kernel arguments; - in
cl.call, only setting the arguments needs to be specialized; the indirect memory handling,clSetKernelExecInfoand the enqueue can be a@noinlinefunction of the kernel, the number of arguments and the keyword arguments; set_args!iteratesenumerateover a heterogeneous tuple, which is type unstable; unrolling it avoids that.
- the mailbox lookup and
After launching 4 signatures, there are 19 specializations of clfunction's keyword body, 13 of launch_with_exception_mailbox's, 14 of cl.set_args! and 8 of the KA launch.
Two problems found in Metal.jl don't apply here: no closure on the launch path captures the kernel or its arguments, and arguments are rooted with GC.@preserve rather than collected in a Vector{Any}.
Note that a reproducer needs to compile the launch inside the timed region, i.e., not let the timing function specialize on (and thus infer) the closure it times:
Reproducer
using pocl_jll, OpenCL, KernelAbstractions, GPUCompiler, Printf
# time spent compiling kernels, to subtract from Julia's compile time
const excluded = Ref(0)
@eval function OpenCL.compile_to_obj(job::OpenCL.OpenCLCompilerJob)
t0 = Base.cumulative_compile_time_ns()[1]
try invoke(OpenCL.compile_to_obj, Tuple{GPUCompiler.CompilerJob}, job)
finally excluded[] += Base.cumulative_compile_time_ns()[1] - t0 end
end
@eval function OpenCL.link_kernel(job::OpenCL.OpenCLCompilerJob, obj::Vector{UInt8}, entry::String)
t0 = Base.cumulative_compile_time_ns()[1]
try invoke(OpenCL.link_kernel, Tuple{GPUCompiler.CompilerJob, Vector{UInt8}, String}, job, obj, entry)
finally excluded[] += Base.cumulative_compile_time_ns()[1] - t0 end
end
function k_native(ys, xs, a)
i = get_global_id()
@inbounds ys[i] = xs[i] * a
return
end
@kernel function k_ka(ys, @Const(xs), a)
i = @index(Global)
@inbounds ys[i] = xs[i] * a
end
run_native(ys, xs, a) = @opencl global_size=64 k_native(ys, xs, a)
run_ka(ys, xs, a) = k_ka(OpenCLBackend())(ys, xs, a; ndrange=64)
function measure(label, @nospecialize(f))
Base.cumulative_compile_timing(true)
excluded[] = 0
t0 = Base.cumulative_compile_time_ns()[1]
f()
host = (Base.cumulative_compile_time_ns()[1] - t0 - excluded[]) / 1e9
@printf("%-20s host compile %5.3f s\n", label, host)
end
mk(T) = (CLArray(zeros(T, 64)), CLArray(ones(T, 64)), T(2))
let (ys, xs, a) = mk(Float32)
run_ka(ys, xs, a); run_native(ys, xs, a); cl.finish(cl.queue())
end
for T in (Int32, Int16, Float16, UInt8)
ys, xs, a = mk(T)
# KA first, so that @opencl doesn't get the per-element-type work
measure("KA $T", () -> (run_ka(ys, xs, a); cl.finish(cl.queue())))
measure("@opencl $T", () -> (run_native(ys, xs, a); cl.finish(cl.queue())))
end
Activity
maleadt commentedon Oct 9, 2026
Confirmed on Linux with PoCL: Ryzen 9 9950X CPU device, pocl_jll 7.2.2+3, Julia 1.13.1, current main
0e4812c, GPUCompiler 2.13.2, LLVM.jl 10.1.0, KernelAbstractions 0.9.44.A small prototype applying only points 1 and 2 (split
clfunctioninto a typed constructor and non-inlined resolver takingCore.MethodInstance, specializesource::S, and change the KA launch toargs::Vararg{Any,N}) gives:@opencl@openclHost compilation is the median across Int32, Int16, Float16 and UInt8 after warming Float32, using the issue's reproducer with
invokelatest(f)inmeasureto keep closure compilation inside the timed region. A second fresh-process comparison agreed. All eight measured launches produced the expected output. Warm enqueue measurements are medians of five 1000-launch batches, synchronized before and after each batch but with synchronization excluded from the timer; bytes are Julia allocations per launch.The remaining launch-chain changes in point 3 were not applied, so this only measures part of the proposed improvement. The system has both system PoCL and JLL PoCL installed; these tests set
OCL_ICD_VENDORSto an empty directory before starting Julia and loadpocl_jll, to isolate the JLL runtime.maleadt commentedon Oct 19, 2026
Fixed by #565 (and #564). With the reproducer on PoCL and Julia 1.12, host compilation per new signature went from ~150 ms (
@opencl) / ~295 ms (KA) right before #565 to ~60 ms / ~110 ms. What remains is mostly in KernelAbstractions 0.10's own launch code.