This file is an agent-facing checklist for writing correct, maintainable, and GPU-compatible Julia code.
Unless explicitly instructed otherwise, treat all rules below as defaults.
- Apply these patterns in new code.
- Prefer refactoring toward these patterns when touching existing code.
- If a rule must be broken, keep the exception narrow and document why.
Avoid string-based model dispatch in hot paths.
Bad:
struct Foo{T}
model::T
end
function baz(f)
if f.model == "ModelA"
# do something ModelA-specific
elseif f.model == "ModelB"
# do something ModelB-specific
end
end
f = Foo("ModelA")
baz(f)Preferred:
struct ModelA end
struct ModelB end
struct Foo{T <: Union{ModelA, ModelB}}
model::T
end
function baz(f)
if f.model isa ModelA
# do something ModelA-specific
elseif f.model isa ModelB
# do something ModelB-specific
end
end
f = Foo(ModelA())
baz(f)Exception:
- Strings are acceptable at initialization boundaries (for example, parsing user/config input), but convert to typed structs as early as possible.
Inside src/, do not introduce new using or import statements that pull names from a sibling or parent submodule of the same package. This rule does not restrict using/import of external packages; those are normal Julia idioms.
Bad:
module Foo
baz() = 1
module Bar
using Foo: baz # same-package cross-submodule import
bing() = baz()
end
endPrefer explicit qualification (Foo.baz() at the call site) or follow the package's existing module convention. This keeps include and initialization order auditable and prevents cycles between submodules.
Avoid symbol-based broadcast patterns. Use concrete, type-stable values or structs.
Struct fields should be concrete/parametric for type stability and performance.
GPU compilers can fail to infer through broadcast inside kernels. Prefer explicit, inference-friendly kernel code. See gpu_performance.md for the canonical definition of "kernel" and "hot path".
Bad:
struct A
f::Function
endPreferred:
struct A{F <: Function}
f::F
endAnything passed into a GPU kernel must be isbits after device adaptation; ClimaCore objects such as Field and Space become isbits only after CUDA.cudaconvert. See gpu_performance.md §8 for the contract and for what to fix when the check fails.
For a struct that does not wrap device-resident arrays, the host-side check suffices; for one that wraps a Field, CuArray, or similar, use the post-adapt check and define Adapt.adapt_structure so the adapted object is isbits:
isbits(A(...)) # no device-resident arrays
isbits(CUDA.cudaconvert(A(...))) # wraps a Field / CuArrayPrefer immutable structs for types that are passed into GPU kernels or broadcast expressions. mutable struct is acceptable for infrastructure types that are never passed into kernels, for example grid objects (ClimaCore.Grids), topologies, and time-stepping integrators (ClimaTimeSteppers.TimeStepperIntegrator).
For fixed-size data, use stack-friendly/static representations.
- Avoid explicit allocators like
collectandreshapein performance-sensitive paths. - Prefer
mapover aTupleorSVector(SDP 23) to accumulation patterns that create temporary arrays.
Use error("static message") instead. Do not capture runtime variables in the error string within a kernel: string interpolation allocates and, on GPU, fails to compile because the device runtime lacks the print_to_string machinery.
Bad:
@assert x > 0 "x must be positive, got $x" # @assert may be removed; interpolation allocatesPreferred:
x > 0 || error("x must be positive") # static message, no interpolationFollow project conventions that avoid @views.
Replace Dict in CPU/GPU kernels with custom structs or NamedTuples.
This rule is strongest for model-side, tendency, and AD-traversed code: prefer function f(x, y) over function f(x::FT, y::FT) where {FT}. The where {FT} form binds every annotated argument to the same type, so it rejects mixed-AD calls (e.g. f(Dual(1.0), 2.0)) and ClimaCore.Fields whose eltypes differ. See ad_compatibility.md for the rationale and the AD-side examples.
Exceptions:
- Struct constructors that statically allocate
SVector/SMatrixneed::Type{FT}to determine the element type at compile time. - Library internals where homogeneous numeric types are intentional (for example, CloudMicrophysics.jl, Thermodynamics.jl) may use
where {FT}broadly. Defer to the package's existing style.
Bad:
# Rejects f(Dual(1.0), 2.0) and any caller with mismatched eltypes
@inline function compute(x::FT, y::FT) where {FT}
return x^2 + y
endPreferred:
# AD-compatible: types inferred from inputs
@inline function compute(x, y)
return x^2 + y
endPrefer FT = eltype(params), FT = typeof(x), or FT = eltype(x) inside function bodies. Avoid repeating {FT} in where clauses for functions that already receive typed inputs.
Bad:
function f(x::AbstractArray{FT}) where {FT}
ε = FT(1e-10)
# ...
endPreferred:
function f(x)
FT = eltype(x)
ε = FT(1e-10)
# ...
endType-agnostic idioms propagate the numeric type, including Dual numbers. Use FT(constant) only for named constants that must be a specific type.
Bad:
acc = FT(0)Preferred:
acc = zero(x)A data-dependent if/else in a GPU kernel causes warp divergence; ifelse(cond, a, b) computes branchlessly. Both arguments are always evaluated, so guard mathematically invalid operations (log, sqrt, division) before the ifelse, not inside a begin...end block inside it.
For the SIMT semantics, why ifelse does not skip work, and the worked log(x) example, see GPU Performance Guide §1. For the broader branch-avoidance discipline (evaluating both arms of a physical case split, combining pointwise conditions with &/| rather than &&/||), see the Branchless Code Guide. For choosing the floor in the pre-guard, see Numerical Robustness §1–2.
Closures that capture multiple local variables produce heap allocations and may fail to compile on GPU (InvalidIRError: unsupported dynamic function invocation). Encapsulate context in a concrete callable struct (functor).
Bad:
f = (x) -> physics_kernel(params, state, x)
result = integrate(f, data)Preferred:
struct PhysicsEval{P, S}
params::P
state::S
end
(e::PhysicsEval)(x) = physics_kernel(e.params, e.state, x)
result = integrate(PhysicsEval(params, state), data)Validation: @allocated integrate(PhysicsEval(params, state), data) should be 0 after a warm-up call.
Convergence-based loops (while err > tol, or for ...; converged && break; end) cause thread divergence when different threads converge at different rates: the warp runs until the slowest point finishes, and the early-exit break is itself a data-dependent branch. Where the physics allows it, use a fixed number of iterations.
Choose the count by physical adequacy (e.g. temperature to ~0.1 K, not to eps(FT)) and validate it with an offline sweep of the conditions a climate run can produce; Thermodynamics.saturation_adjustment (a fixed maxiter = 2 safeguarded Newton solve with no stopping test) is the canonical example. For the methodology, the offline-test checklist, and the worked example, see the Branchless Code Guide §4–5.
Capturing a parameter struct or thermodynamic parameter container directly inside a @. broadcast expression forces the broadcast engine to determine its broadcast shape at runtime, and capturing the full p.params container can push a large struct into GPU kernel parameter memory and exceed hardware limits. Extracting the sub-struct to a named local variable before the broadcast makes the broadcast shape unambiguous to the compiler, prevents shape-mismatch errors in ClimaCore's field-space broadcast engine, keeps the kernel argument small, and keeps the broadcast expression readable.
Bad:
@. result = my_physics(p.params.thermodynamics_params, Y.c.T, Y.c.ρ)Preferred:
thp = p.params.thermodynamics_params
@. result = my_physics(thp, Y.c.T, Y.c.ρ)Ref() is not the broadcast-escape pattern in CliMA code; it appears only as a mutable scalar box in callbacks and other non-broadcast contexts. Extract parameters as above.
Keyword arguments introduce a sorter trampoline that can prevent inlining and trigger dynamic dispatch on GPU compilers. Use positional arguments; pass parameter containers instead of individual named constants.
Bad:
@inline function transform(T, θ; L_v = 2.5e6, c_p = 1004)
# ...
endPreferred:
@inline function transform(params, T, θ)
L_v = get_latent_heat(params)
c_p = get_cp(params)
# ...
endPrefer @safetestset over @testset + nested include so variables and imports do not leak between test files. See testing_and_validation.md for the pattern and per-repo conventions.
Avoid list comprehensions like [getproperty(dist, p) for p in params] in hot paths or GPU code: they allocate an Array on the heap.
Use map over a Tuple or SVector, which returns a Tuple or SVector respectively without heap allocation. The rule is about the input type: map over a Vector / Array still allocates a new Array.
# Bad: allocates a Vector
x = [f(p) for p in params] # params is a Vector
# Preferred: input is a Tuple → map returns a Tuple, no allocation
x = map(f, (a, b, c))
# Preferred: input is an SVector → map returns an SVector
x = map(f, SVector(a, b, c))Generated functions have much higher compilation latency than ordinary functions.
To limit compilation time, use generated functions only when necessary, which includes the following situations:
- Guaranteeing inlining or unrolling of code, to avoid compilation errors when the compiler fails to perform these optimizations.
- Dynamically constructing a
Stringinside a GPU kernel, which requires runtime allocations. - Dynamically constructing a
Symbolinside a GPU kernel, sinceSymbols are implemented as interned strings.
If this guide is discovered to be stale or missing a pattern, update it.