Procedures for locating and removing heap allocations in CliMA Julia code. The companion rules (what counts as a hot path and which patterns to use) live in gpu_performance.md. This guide is the debug-loop counterpart: what to do when @allocated is not zero.
Every CliMA repo with a numerical hot path pins zero allocations with a unit test. The canonical pattern (warm up, then assert) appears in Thermodynamics.test/type_stability.jl, ClimaTimeSteppers.test/..., and the precomputed-quantity setters of every model repo:
# Warm up: compiles and fills first-call caches
my_hot_function(args...)
# Assert
@test (@allocated my_hot_function(args...)) == 0Two properties of @allocated:
- It returns the bytes heap-allocated while the expression runs. The first invocation is dominated by compilation and is never zero, so always warm up first.
- It counts heap allocations only; stack allocations and inlined boxes are invisible to it, so a
0is a strong signal.
The following sections localize the cause when the test fails.
@allocated reports that an allocation happened; Profile.Allocs (in the Profile stdlib) reports where. The pattern used in CloudMicrophysics.test/performance_tests.jl:
import Profile
Profile.clear()
Profile.Allocs.@profile sample_rate = 1 my_hot_function(args...)
results = Profile.Allocs.fetch()
sorted = sort(results.allocs, by = x -> x.size)
for a in sorted
println(a) # type and size
println(a.stacktrace) # where it came from
endTips:
sample_rate = 1records every allocation; lower rates (e.g.0.01) suit longer runs.- The largest allocation is usually the most informative: sort by
.sizeand start from the bottom. - The stack trace points at the allocating line, not the root cause. A boxed closure variable shows as a
Core.Boxallocation deep inside the kernel; the fix is upstream, where the closure was constructed.
A common source of allocations is a single dynamic dispatch buried in an otherwise typed function. Profile.Allocs shows that jl_apply_generic allocated; JET.@report_opt shows which call site the compiler failed to resolve. Used in CloudMicrophysics.test/performance_tests.jl (JET.@test_opt) and ClimaTimeSteppers.perf/jet.jl:
using JET
JET.@report_opt my_hot_function(args...)JET prints a list of runtime dispatch detected entries with the unresolved call and the failing argument type. Reading order:
- Find the deepest call that is yours (not a stdlib or third-party method).
- Look at the argument types: a
Union{...},Any, orFunctionis the sign. Track that argument back to where it lost type information (a struct field typed::Functioninstead of::F, aVector{Any}, aUnion{Nothing, T}field accessed without dispatch; see SDP 6 and type_stability.md §3). - Fix the upstream type instability; the JET entry disappears and the allocation goes with it.
Use JET.@test_opt my_hot_function(args...) in a @testset for a CI-style regression gate.
When the suspect is a single function, @code_warntype my_hot_function(args...) prints what the compiler inferred, with non-concrete types in red (type_stability.md §2). Read the Body::T line first: if T is Any or a Union, the return type is unstable. Then scan for red Union{...} annotations in the body. For deep call graphs, use JET (§3).
The regression test is pass/fail. To characterize allocations and their cost over many calls, use BenchmarkTools, as in CloudMicrophysics.test/performance_tests.jl and ClimaTimeSteppers.perf/jet.jl:
using BenchmarkTools
trial = @benchmark $(splat(my_hot_function))($args) samples = 100 evals = 100
# trial.memory: total bytes allocated per call
# trial.allocs: number of distinct allocations per call
# minimum(trial): most useful time estimate (least affected by GC noise)Two pitfalls:
- Always interpolate (
$x) into the macro so the benchmarked expression sees a value rather than a global. $foo($args...)can itself allocate from splatting; prefer$(splat(foo))($args)or pass arguments individually.
For longer hot paths (e.g. one full RK step), a flame graph shows where time and allocations cluster. The pattern in ClimaTimeSteppers.perf/flame.jl:
using Profile, ProfileCanvas
Profile.clear()
@profile for _ in 1:100_000; my_step!(args...); end
ProfileCanvas.html_file("flame.html") # save artifact; or .view() locallyClimaAtmos's perf/flame.jl profiles a single timestep the same way.
Symptom (in Profile.Allocs stacktrace) |
Likely cause | Fix |
|---|---|---|
Core.Box deep inside a kernel |
Captured local variable in a closure | Convert closure to a functor (SDP 18) |
jl_apply_generic with no obvious source |
Dynamic dispatch on a Function/Any field |
Parameterize the field (SDP 6) |
Allocation inside a @. broadcast |
Non-isbits argument captured in the broadcast |
Extract parameters to locals (SDP 20); or check isbits(cudaconvert(...)) (gpu_performance.md §8) |
Array{Float64} allocation in a Float32 path |
A Float64 literal promoting the result |
Track down the literal (type_stability.md §1) |
Union{T, Nothing} access in a hot path |
Optional field forces a runtime check | Split the method by the field's type parameter (type_stability.md §3) |
Allocation inside ifelse |
A begin...end block or non-isbits branch result |
Pre-compute both branches outside ifelse (SDP 17) |
| Allocation only on GPU, not CPU | A kernel-incompatible operation (string interp, Dict) | See gpu_performance.md §7 and SDP 13 |
Three cases where @allocated == 0 misleads:
- The allocation happens during compilation, not execution. First-call allocations are a real, one-time cost. If startup time matters (e.g. precompile-sensitive jobs), profile with
--trace-compile=stderr. - The allocation happens on the GPU side.
@allocatedmeasures host allocations only, and a kernel launch allocates host-side wrappers, so the zero-allocation sentinel is a CPU-only test. UseCUDA.@timeorCUDA.memory_status()to see device allocations. - The allocation happens elsewhere in the call graph. A function can be allocation-free locally while its caller (a wrapping
@.broadcast, or abycolumniterator) allocates. Pin the test at the outermost layer that runs per timestep.
If this guide is discovered to be stale or missing a pattern, update it.