Practical recipes for finding and fixing 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: forces compilation, fills in any first-call caches
my_hot_function(args...)
# Assert
@test (@allocated my_hot_function(args...)) == 0Two things to know:
@allocatedreturns bytes allocated since the last call. The first invocation is dominated by compilation and is never zero, so always warm up first.@allocatedignores stack allocations and small inlined boxes; it counts genuine heap allocations. So0from@allocatedis a strong signal.
When the test fails, the next sections tell you how to localize the cause.
@allocated tells you that there is an allocation but not where. Profile.Allocs (a stdlib module on currently supported Julia versions) does. 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) are appropriate for 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 will show as an allocation in
Core.Boxdeep 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 will show that jl_apply_generic allocated; JET.@report_opt will show 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 giveaway. Track that argument back to where it lost type information (a struct field typed::Functioninstead of::F, aVector{Any}, anUnion{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 and you want to see what the compiler inferred, @code_warntype highlights any non-concrete inferred types in red. Useful for the smallest reproducer; less useful for deep call graphs (use JET there).
@code_warntype my_hot_function(args...)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.
@allocated is binary (zero or not zero). For characterizing 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 (resilient to GC noise)Two pitfalls:
- Always interpolate (
$x) into the macro so the benchmarked function sees the value, not a global ref. $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() locallyA model-repo equivalent (e.g. perf/flame.jl in ClimaAtmos) profiles a single timestep; the same idea applies to library code.
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 failure modes where @allocated == 0 is misleading:
- The allocation happens during compilation, not execution. First-call allocations are real cost, just amortized. If startup time matters (e.g. precompile-sensitive jobs), profile with
--trace-compile=stderr. - The allocation happens on the GPU side.
@allocatedonly measures host allocations. 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, not at a leaf function.
If this guide is discovered to be stale or missing a pattern, update it.