This file is an agent-facing checklist for writing robust, 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 whatever module-wiring convention the package already uses. The goal is to keep include/initialization order auditable and prevent accidental cycles between submodules.
Avoid symbol-based broadcast patterns. Use concrete, type-stable values/structures instead.
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, not necessarily on the host. ClimaCore objects (e.g. Field, Space) are deliberately not isbits on the host and become isbits only after Adapt.adapt(CUDA.KernelAdaptor(), x) (i.e. CUDA.cudaconvert(x)).
For a new struct that does not wrap device-resident arrays, the host-side check suffices:
isbits(A(...))For a struct that does wrap a Field, CuArray, or similar, the meaningful check is the post-adapt one (see gpu_performance.md §8):
isbits(CUDA.cudaconvert(A(...)))In the wrapping case, also define Adapt.adapt_structure so the post-adapt object actually becomes isbits.
Prefer 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 allocation-light transforms (for example,
map) instead of manual accumulate 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, typically fails to compile because the device runtime lacks the full 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.
Dict is not allowed in CPU/GPU kernels. Replace 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 concrete element type, which rejects mixed-AD calls (e.g. f(Dual(1.0), 2.0)) and rejects ClimaCore.Fields whose eltypes differ from each other or from a Float. Duck typing lets each argument carry its own type and lets AD flow through naturally.
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 numeric type (including Dual numbers) without an explicit conversion. 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 full explanation (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 (including evaluating both arms of a physical case split and combining pointwise conditions with &/| rather than &&/||), see the Branchless Code Guide. For choosing the right 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) instead.
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, prefer a fixed number of iterations so all threads in a warp follow the same execution path.
Fix the count by physical adequacy (e.g. temperature to ~0.1 K, not to eps(FT)), and determine it with an offline test that sweeps the full range of conditions a climate run can produce. The canonical example is Thermodynamics.saturation_adjustment (a fixed maxiter = 2 Newton solve, no convergence flag). For the methodology, the offline-test checklist, and the worked example, see the Branchless Code Guide §4–5.
Capturing complex parameter structs or thermodynamic parameter containers directly inside a @. broadcast expression forces the broadcast engine to determine their broadcast shape at runtime. Extracting them to named local variables before the broadcast (a) makes the broadcast shape unambiguous to the compiler, (b) prevents potential shape-mismatch errors in ClimaCore's field-space broadcast engine, and (c) 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.ρ)Note: Ref() is not the standard broadcast-escape pattern in this codebase. Its current use is limited to mutable scalar boxes in callbacks and non-broadcast contexts. Prefer parameter extraction.
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, as they explicitly allocate Arrays 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 are versatile and helpful for debugging, but they have significantly higher compilation latencies than non-generated functions.
To minimize compilation time, only use generated functions when absolutely necessary. This 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.