Skip to content

Commit 6ca2c71

Browse files
dennisYatuninclaude
andcommitted
Add TestCompilation, a device-free CPU/GPU compilation checker
compilation_reports and @test_compilation analyze a call in four stages, none of which need a GPU: JET over the CPU argument types (equivalent to JET.@test_opt), JET over the CuArray-backed types seen in GPU CI jobs, GPUCompiler IR validation plus JET over CUDA's device method table for the kernel-side representation (derived with the same KernelAdaptor rules as real launches), and a scan for host arrays that survive adaptation, which would become illegal host pointers inside kernels. Includes unit tests and a Buildkite step for them. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent a7081e2 commit 6ca2c71

3 files changed

Lines changed: 420 additions & 0 deletions

File tree

.buildkite/pipeline.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,11 @@ steps:
101101
retry: *retry_policy
102102
command: "julia --color=yes --check-bounds=yes --project=.buildkite test/Utilities/unit_auto_broadcaster.jl"
103103

104+
- label: "Unit: test compilation checker"
105+
key: unit_test_compilation
106+
retry: *retry_policy
107+
command: "julia --color=yes --check-bounds=yes --project=.buildkite test/Utilities/unit_test_compilation.jl"
108+
104109
- group: "Unit: DataLayouts"
105110
steps:
106111

Lines changed: 309 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,309 @@
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

Comments
 (0)