|
| 1 | +""" |
| 2 | + TestCompilation |
| 3 | +
|
| 4 | +Device-free compilation checking for CPU and GPU code paths. No GPU is needed |
| 5 | +for any check in this module (`CUDA.functional()` may be `false`), so tests |
| 6 | +built on it can guarantee GPU compilation without requesting CUDA devices. |
| 7 | +
|
| 8 | +Given a call `f(args...)` with CPU (`Array`-backed) arguments, this module can |
| 9 | +run four analyses: |
| 10 | +
|
| 11 | + 1. `:cpu` — JET's optimization analysis of the call itself, equivalent to |
| 12 | + `JET.@test_opt f(args...)`: reports every runtime dispatch or optimization |
| 13 | + failure over the CPU argument types. |
| 14 | + 2. `:host` — the same JET analysis over the argument types as they would |
| 15 | + appear on a machine with a GPU: every `Array` becomes a `CuArray`, CPU |
| 16 | + devices become `ClimaComms.CUDADevice`, and `DataLayouts` scopes are |
| 17 | + recomputed. This is what `JET.@test_opt` sees in GPU CI jobs, and it |
| 18 | + catches host-side instabilities in kernel launch code. |
| 19 | + 3. `:kernel` — GPU device code analysis. The arguments are converted with the |
| 20 | + same `Adapt`/`CUDA.KernelAdaptor` rules that real kernel launches use |
| 21 | + (with `Array` leaves standing in for `CuArray`s), and the call is treated |
| 22 | + as a kernel body. Two analyses run on it: |
| 23 | + - GPUCompiler's LLVM IR validation, which catches `InvalidIRError`s |
| 24 | + (dynamic dispatch, GPU-illegal operations) at the stage right before |
| 25 | + the IR would be compiled to PTX; and |
| 26 | + - JET's optimization analysis over CUDA's device method table, so that |
| 27 | + device intrinsics like `threadIdx` are not treated as dead code. |
| 28 | + 4. `:pointers` — a scan of the adapted arguments for host arrays that |
| 29 | + survived adaptation. A field that remains an `Array` after the |
| 30 | + `KernelAdaptor` runs corresponds to a host pointer inside a kernel |
| 31 | + argument on a real GPU, which causes an illegal memory access at runtime |
| 32 | + (this cannot be caught by compilation alone). |
| 33 | +
|
| 34 | +The `:kernel` check relies on scope-based dispatch: converting a `DataLayout` |
| 35 | +to its kernel-side representation gives it the `ThisKernel` scope, so calling |
| 36 | +the same user-facing function on adapted arguments follows the device |
| 37 | +implementation. Host functions that are not scope-dispatched (e.g. ones that |
| 38 | +call CUDA APIs directly) should be checked with `stages = (:cpu, :host)`. |
| 39 | +
|
| 40 | +# Usage |
| 41 | +
|
| 42 | + using .TestCompilation |
| 43 | +
|
| 44 | + # In a @testset (with args constructed on the CPU): |
| 45 | + @test_compilation fill!(data, value) |
| 46 | + @test_compilation stages = (:cpu, :host) column_integral_definite!(∫u, u) |
| 47 | +
|
| 48 | + # Programmatically: |
| 49 | + ok, issues = compilation_reports(fill!, (data, value)) |
| 50 | + ok, issues = compilation_reports(f, args; stages = (:cpu,), ignored_modules = (...,)) |
| 51 | +
|
| 52 | +Each entry of `issues` is prefixed with the stage that produced it: `[cpu]`, |
| 53 | +`[host]`, `[kernel IR]`, `[kernel JET]`, or `[pointers]`. Keyword arguments |
| 54 | +other than `stages` are forwarded to `JET.report_opt` (e.g. `function_filter` |
| 55 | +and `ignored_modules`). |
| 56 | +""" |
| 57 | +module TestCompilation |
| 58 | + |
| 59 | +import Adapt |
| 60 | +import CUDA |
| 61 | +import ClimaComms |
| 62 | +import ClimaCore |
| 63 | +import ClimaCore: DataLayouts |
| 64 | +import JET |
| 65 | +import Test |
| 66 | + |
| 67 | +const CC = Core.Compiler |
| 68 | +const GC = CUDA.GPUCompiler |
| 69 | + |
| 70 | +export compilation_reports, @test_compilation |
| 71 | + |
| 72 | +# Frames that every ClimaCore JET test ignores: kernel launches unavoidably |
| 73 | +# pass through dynamic code in CUDA.jl and the CUDA extension (kernel caching, |
| 74 | +# argument conversion), and thread launches pass through dynamic error paths in |
| 75 | +# Base.Threads' task-spawning code. Functions parallelized over threads are |
| 76 | +# still fully analyzed, since inference also follows the branch that runs them |
| 77 | +# without spawning tasks (used for nested threaded loops). |
| 78 | +default_ignored_modules() = ( |
| 79 | + JET.AnyFrameModule(CUDA), |
| 80 | + JET.AnyFrameModule(Base.get_extension(ClimaCore, :ClimaCoreCUDAExt)), |
| 81 | + JET.AnyFrameModule(Base.Threads), |
| 82 | +) |
| 83 | + |
| 84 | +# ============================================================================= |
| 85 | +# Argument conversion |
| 86 | +# ============================================================================= |
| 87 | + |
| 88 | +# Stand-in for CuArray -> CuDeviceArray on the real launch path: a null-pointer |
| 89 | +# CuDeviceArray is a plain isbits struct, so it can be constructed without a |
| 90 | +# GPU, and going through the genuine KernelAdaptor applies every |
| 91 | +# package-specific adapt rule (e.g. grids, spaces, and limiter internals). |
| 92 | +struct KernelArrayStandIn end |
| 93 | +Adapt.adapt_storage(::KernelArrayStandIn, a::Array{T, N}) where {T, N} = |
| 94 | + CUDA.CuDeviceArray{T, N, CUDA.AS.Global}( |
| 95 | + reinterpret(Core.LLVMPtr{T, CUDA.AS.Global}, C_NULL), |
| 96 | + size(a), |
| 97 | + ) |
| 98 | +Adapt.adapt_storage(to::KernelArrayStandIn, x) = |
| 99 | + Adapt.adapt_storage(CUDA.KernelAdaptor(), x) |
| 100 | + |
| 101 | +kernel_arguments(args) = |
| 102 | + map(args) do arg |
| 103 | + standin = Adapt.adapt(KernelArrayStandIn(), arg) |
| 104 | + Adapt.adapt(CUDA.KernelAdaptor(), standin) |
| 105 | + end |
| 106 | + |
| 107 | +# Rewrite a host type to the type it would have on a machine with a GPU: |
| 108 | +# Arrays become CuArrays, CPU devices become CUDADevice, and DataLayouts scopes |
| 109 | +# are recomputed from the new parent array types. |
| 110 | +function host_gpu_type(@nospecialize(T)) |
| 111 | + T isa Type || return T |
| 112 | + T <: ClimaComms.AbstractCPUDevice && return ClimaComms.CUDADevice |
| 113 | + if T isa DataType && T <: Array |
| 114 | + return CUDA.CuArray{eltype(T), ndims(T), CUDA.DeviceMemory} |
| 115 | + elseif T isa DataType && |
| 116 | + T <: DataLayouts.DataLayout && |
| 117 | + length(T.parameters) >= 2 |
| 118 | + params = collect(T.parameters) |
| 119 | + A = host_gpu_type(params[end]) |
| 120 | + S = typeof(DataLayouts.DataScope(A)) |
| 121 | + return T.name.wrapper{map(host_gpu_type, params[1:(end - 2)])..., S, A} |
| 122 | + elseif T isa DataType && !isempty(T.parameters) |
| 123 | + try |
| 124 | + return T.name.wrapper{map(host_gpu_type, T.parameters)...} |
| 125 | + catch |
| 126 | + return T |
| 127 | + end |
| 128 | + else |
| 129 | + return T |
| 130 | + end |
| 131 | +end |
| 132 | + |
| 133 | +# ============================================================================= |
| 134 | +# JET over the CUDA device method table (for kernel-side analysis) |
| 135 | +# ============================================================================= |
| 136 | + |
| 137 | +# JET's default OptAnalyzer infers with the native method table, where CUDA |
| 138 | +# intrinsics like threadIdx resolve to host definitions that just throw, so |
| 139 | +# every kernel body looks like dead code. This report pass behaves identically |
| 140 | +# to JET's OptAnalysisPass but routes inference through CUDA's device method |
| 141 | +# table (and gets its own analysis cache, since JET caches per report pass). |
| 142 | +struct DeviceOptPass <: JET.ReportPass end |
| 143 | +(::DeviceOptPass)(T::Type{<:JET.InferenceErrorReport}, args...) = |
| 144 | + JET.OptAnalysisPass()(T, args...) |
| 145 | +method_table_for(::JET.OptAnalysisPass, world::UInt) = CC.InternalMethodTable(world) |
| 146 | +method_table_for(::DeviceOptPass, world::UInt) = |
| 147 | + GC.get_method_table_view(world, CUDA.method_table) |
| 148 | +CC.method_table(analyzer::JET.OptAnalyzer) = |
| 149 | + method_table_for(JET.ReportPass(analyzer), JET.get_inference_world(analyzer)) |
| 150 | + |
| 151 | +function jet_issues(prefix, f, tt; device = false, jetconfigs...) |
| 152 | + result = |
| 153 | + device ? |
| 154 | + JET.report_opt(f, tt; report_pass = DeviceOptPass(), jetconfigs...) : |
| 155 | + JET.report_opt(f, tt; jetconfigs...) |
| 156 | + return map(JET.get_reports(result)) do report |
| 157 | + message = sprint(JET.print_report, report) |
| 158 | + location = |
| 159 | + isempty(report.vst) ? "" : |
| 160 | + string(" @ ", last(report.vst).file, ':', last(report.vst).line) |
| 161 | + string(prefix, ' ', message, location) |
| 162 | + end |
| 163 | +end |
| 164 | + |
| 165 | +# ============================================================================= |
| 166 | +# GPUCompiler IR validation (for kernel-side analysis) |
| 167 | +# ============================================================================= |
| 168 | + |
| 169 | +const PTX_CAP = v"7.0" |
| 170 | +const PTX_ISA = v"7.8" |
| 171 | + |
| 172 | +# Missing symbols that only resolve during a real kernel launch: libdevice |
| 173 | +# math functions, GPU runtime helpers, and the kernel state intrinsic. |
| 174 | +is_benign_ir_error(e) = |
| 175 | + e[1] == GC.UNKNOWN_FUNCTION && |
| 176 | + e[3] isa AbstractString && |
| 177 | + ( |
| 178 | + startswith(e[3], "__nv") || |
| 179 | + startswith(e[3], "gpu_") || |
| 180 | + occursin("state_getter", e[3]) |
| 181 | + ) |
| 182 | + |
| 183 | +function ir_issues(f, tt) |
| 184 | + config = GC.CompilerConfig( |
| 185 | + GC.PTXCompilerTarget(; cap = PTX_CAP, ptx = PTX_ISA), |
| 186 | + CUDA.CUDACompilerParams(; cap = PTX_CAP, ptx = PTX_ISA); |
| 187 | + kernel = true, |
| 188 | + libraries = false, |
| 189 | + always_inline = true, |
| 190 | + ) |
| 191 | + try |
| 192 | + job = GC.CompilerJob(GC.methodinstance(typeof(f), tt), config) |
| 193 | + GC.JuliaContext() do _ |
| 194 | + GC.compile(:llvm, job) |
| 195 | + end |
| 196 | + return String[] |
| 197 | + catch e |
| 198 | + e isa GC.InvalidIRError || rethrow() |
| 199 | + errors = filter(!is_benign_ir_error, e.errors) |
| 200 | + return unique(map(errors) do (kind, _, meta) |
| 201 | + string("[kernel IR] ", kind, meta isa Nothing ? "" : " [$meta]") |
| 202 | + end) |
| 203 | + end |
| 204 | +end |
| 205 | + |
| 206 | +# ============================================================================= |
| 207 | +# Host pointer scan (for kernel arguments after adaptation) |
| 208 | +# ============================================================================= |
| 209 | + |
| 210 | +function host_pointer_issues!(issues, x, path) |
| 211 | + if x isa Array || x isa Ptr |
| 212 | + push!( |
| 213 | + issues, |
| 214 | + "[pointers] $path is a host $(typeof(x).name.wrapper), \ |
| 215 | + which would cause an illegal memory access in a kernel", |
| 216 | + ) |
| 217 | + elseif x isa CUDA.CuDeviceArray |
| 218 | + return issues |
| 219 | + elseif !isbits(x) && (isstructtype(typeof(x)) || x isa Tuple) |
| 220 | + foreach(1:fieldcount(typeof(x))) do i |
| 221 | + name = x isa Tuple ? "[$i]" : string('.', fieldname(typeof(x), i)) |
| 222 | + isdefined(x, i) && |
| 223 | + host_pointer_issues!(issues, getfield(x, i), path * name) |
| 224 | + end |
| 225 | + end |
| 226 | + return issues |
| 227 | +end |
| 228 | + |
| 229 | +# ============================================================================= |
| 230 | +# Combined checks |
| 231 | +# ============================================================================= |
| 232 | + |
| 233 | +""" |
| 234 | + compilation_reports(f, args::Tuple; stages, jetconfigs...) |
| 235 | + -> (ok::Bool, issues::Vector{String}) |
| 236 | +
|
| 237 | +Run the [`TestCompilation`](@ref) analyses of `f(args...)` selected by |
| 238 | +`stages` (any subset of `(:cpu, :host, :kernel, :pointers)`; all by default). |
| 239 | +`ok` is `true` when every selected analysis finds no issues. All other keyword |
| 240 | +arguments are forwarded to `JET.report_opt`. |
| 241 | +""" |
| 242 | +function compilation_reports( |
| 243 | + f, |
| 244 | + args::Tuple; |
| 245 | + stages = (:cpu, :host, :kernel, :pointers), |
| 246 | + ignored_modules = default_ignored_modules(), |
| 247 | + jetconfigs..., |
| 248 | +) |
| 249 | + jetconfigs = (; ignored_modules, jetconfigs...) |
| 250 | + issues = String[] |
| 251 | + cpu_tt = Tuple{map(typeof, args)...} |
| 252 | + :cpu in stages && append!(issues, jet_issues("[cpu]", f, cpu_tt; jetconfigs...)) |
| 253 | + :host in stages && append!( |
| 254 | + issues, |
| 255 | + jet_issues("[host]", f, host_gpu_type(cpu_tt); jetconfigs...), |
| 256 | + ) |
| 257 | + if :kernel in stages || :pointers in stages |
| 258 | + adapted = kernel_arguments(args) |
| 259 | + if :pointers in stages |
| 260 | + foreach(enumerate(adapted)) do (i, arg) |
| 261 | + host_pointer_issues!(issues, arg, "args[$i]") |
| 262 | + end |
| 263 | + end |
| 264 | + if :kernel in stages |
| 265 | + kernel_f = (args...) -> (f(args...); nothing) |
| 266 | + kernel_tt = Tuple{map(typeof, adapted)...} |
| 267 | + append!(issues, ir_issues(kernel_f, kernel_tt)) |
| 268 | + append!( |
| 269 | + issues, |
| 270 | + jet_issues( |
| 271 | + "[kernel JET]", |
| 272 | + kernel_f, |
| 273 | + kernel_tt; |
| 274 | + device = true, |
| 275 | + jetconfigs..., |
| 276 | + ), |
| 277 | + ) |
| 278 | + end |
| 279 | + end |
| 280 | + return isempty(issues), issues |
| 281 | +end |
| 282 | + |
| 283 | +""" |
| 284 | + @test_compilation [stages = ...] [kwarg = ...] f(args...) |
| 285 | +
|
| 286 | +Assert (via `Test.@test`) that `f(args...)` passes the selected |
| 287 | +[`compilation_reports`](@ref) analyses. On failure, the issues are logged. |
| 288 | +
|
| 289 | + @test_compilation fill!(data, value) |
| 290 | + @test_compilation stages = (:cpu, :host) column_integral_definite!(∫u, u) |
| 291 | +""" |
| 292 | +macro test_compilation(args...) |
| 293 | + call = args[end] |
| 294 | + kwargs = map(args[1:(end - 1)]) do kwarg |
| 295 | + @assert Meta.isexpr(kwarg, :(=), 2) "expected keyword arguments before the call" |
| 296 | + Expr(:kw, kwarg.args[1], esc(kwarg.args[2])) |
| 297 | + end |
| 298 | + @assert Meta.isexpr(call, :call) "expected a function call as the last argument" |
| 299 | + f = esc(call.args[1]) |
| 300 | + call_args = map(esc, call.args[2:end]) |
| 301 | + quote |
| 302 | + local ok, issues = |
| 303 | + compilation_reports($f, ($(call_args...),); $(kwargs...)) |
| 304 | + ok || @info "Compilation issues for $($(string(call)))" issues |
| 305 | + Test.@test ok |
| 306 | + end |
| 307 | +end |
| 308 | + |
| 309 | +end # module TestCompilation |
0 commit comments