How to understand reasons for some unexpected heap allocations

I’m really surprised by some heap allocations being made within a package I maintain. I don’t expect them because:

  1. I’ve use Cthulhu to ensure that all variables in the code are concretely typed.
  2. The variables in question are all fairly small (192 bytes max!), including one Int64.
  3. Some of the variables are mutable structs, but none of these escape the function in which they’re created.
  4. Several of the variables are immutable return values, and annotating the return type of the relevant functions makes no difference; they’re concretely typed anyway.

As well as using Cthulhu, I’ve used the allocation profiler to see where and what the allocations are, but given what I said above, I’m unable to understand why the allocations are occurring (in Julia Version 1.12.6). It’s my belief that I should be able to avoid them, and that the code should (in principle) require no heap allocations at all.

Here is a screenshot of the allocations being made:


Does anyone have any further tips for understanding the cause of such allocations?

If anyone wants to recreate the problem, you can do so with the following code, which should (using latest NLLSsolver, v4.0.5) produce the same graph as above:

using NLLSsolver, Random, Static, StaticArrays, LinearAlgebra, Profile, PProf

# Simple affine projection transform
NLLSsolver.generatemeasurement(pose::EuclideanVector{6, T}, X::EuclideanVector{3, U}) where {T, U} = SVector(dot(@inbounds(view(pose, NLLSsolver.SR(1, 3))), X), dot(@inbounds(view(pose, NLLSsolver.SR(4, 6))), X))
MyResType = SimpleError2{2, Float64, EuclideanVector{6, Float64}, EuclideanVector{3, Float64}}

function create_ba_problem(ncameras, nlandmarks, propvisible)
    problem = NLLSProblem(Union{EuclideanVector{6, Float64}, EuclideanVector{3, Float64}}, MyResType)

    # Generate the cameras on a unit sphere, pointing to the origin
    camoffset = SVector(1.0, 0.0, 0.0, 0.0, 1.0, 0.0)
    for i = 1:ncameras
        addvariable!(problem, randn(EuclideanVector{6, Float64}) .+ camoffset)
    end
    
    # Generate the landmarks in a unit cube centered on the origin
    lmoffset = SVector(-0.5, -0.5, 10.0)
    for i = 1:nlandmarks
        addvariable!(problem, rand(EuclideanVector{3, Float64}) .+ lmoffset)
    end

    # Generate the measurements
    visibility = abs.(repeat(vec(1:ncameras), outer=(1, nlandmarks)) .- LinRange(2, ncameras-1, nlandmarks)')
    visibility = visibility .<= sort(vec(visibility))[Int(ceil(length(visibility)*propvisible))]
    for camind = 1:ncameras
        camera = problem.variables[camind]::EuclideanVector{6, Float64}
        for (landmark, tf) in enumerate(view(visibility, camind, :)')
            if tf
                landmarkind = landmark + ncameras
                addcost!(problem, SimpleError2{EuclideanVector{6, Float64}, EuclideanVector{3, Float64}}(generatemeasurement(camera, problem.variables[landmarkind]::EuclideanVector{3, Float64}), camind, landmarkind))
            end
        end
    end

    # Return the NLLSProblem
    return problem
end

function perturb_ba_problem(problem, pointnoise, posenoise)
    for ind in 1:lastindex(problem.variables)
        if isa(problem.variables[ind], EuclideanVector{3, Float64})
            problem.variables[ind]::EuclideanVector{3, Float64} += randn(SVector{3, Float64}) * pointnoise
        else
            problem.variables[ind]::EuclideanVector{6, Float64} += randn(SVector{6, Float64}) * posenoise
        end
    end
    return problem
end

Random.seed!(1)
problem = create_ba_problem(3, 1, 1.0)
problem = perturb_ba_problem(problem, 0.003, 0.0)   
options = NLLSOptions(numthreads = static(1))
function myfun(problem, options)
    result = optimize!(problem, options, 4)
    problem = perturb_ba_problem(problem, 0.003, 0.0)
    Profile.clear_malloc_data()
    Profile.Allocs.clear()
    Profile.Allocs.@profile sample_rate=1.0 optimize!(problem, options, 4)
end
myfun(problem, options)
PProf.Allocs.pprof(from_c = false)

I’d be interested in tooling that exposes escape analysis because proving bounded lifetimes is apparently not as simple as what we intend. AllocCheck.jl is nice but it doesn’t say much.

In my experience, one also needs that all functions that are called with the mutable struct as an argument are inlined. Do you have that?

EDIT: Also, are you sure that no mutable struct escapes? For example, one MVector{3,Float64} is created in line @NLLSsolver/src/linearsystem.jl:71 as part of the struct UniVariateLSstatic_x{3} and then returned to line @NLLSsolver/src/linearsystem.jl:25. Ditto for the other one and the MMatrix{3,3,Float64,9}, both of which are created in line @NLLSsolver/src/linearsystem.jl:63 and returned to @NLLSsolver/src/linearsystem.jl:25.

JET.jl reports a possible type error exactly at the location of at least some of the allocations (@NLLSsolver/src/optimize.jl:117)

julia> using JET, NLLSsolver

julia> @report_call optimize!(problem, options, 4)
[ Info: tracking NLLSsolver
[ Info: tracking Base
═════ 1 possible error found ═════
┌ optimize!(problem::NLLSProblem{…}, options::NLLSOptions{…}, unfixed::Int64) @ NLLSsolver /mnt/data/git/NLLSsolver.jl/src/optimize.jl:2
│┌ optimize!(problem::NLLSProblem{…}, options::NLLSOptions{…}, unfixed::Int64, callback::typeof(nullcallback)) @ NLLSsolver /mnt/data/git/NLLSsolver.jl/src/optimize.jl:2
││┌ setupsinglevarls(func::typeof(NLLSsolver.optimizeinternal!), problem::NLLSProblem{…}, options::NLLSOptions{…}, unfixed::Int64, startstats::NLLSsolver.Stats, trailingargs::typeof(nullcallback)) @ NLLSsolver /mnt/data/git/NLLSsolver.jl/src/optimize.jl:108
│││┌ setupstaticvarls(func::typeof(NLLSsolver.optimizeinternal!), problem::NLLSProblem{…}, options::NLLSOptions{…}, unfixed::Int64, startstats::NLLSsolver.Stats, varlen::StaticInt{…}, trailingargs::Tuple{…}) @ NLLSsolver /mnt/data/git/NLLSsolver.jl/src/optimize.jl:117
││││┌ optimizeinternal!(problem::NLLSProblem{…}, options::NLLSOptions{…}, data::NLLSsolver.NLLSInternal{…}, iteratedata::NLLSsolver.LevMarData, callback::typeof(nullcallback)) @ NLLSsolver /mnt/data/git/NLLSsolver.jl/src/optimize.jl:228
│││││┌ optimizeloop!(problem::NLLSProblem{…}, options::NLLSOptions{…}, data::NLLSsolver.NLLSInternal{…}, iteratedata::NLLSsolver.LevMarData, callback::typeof(nullcallback)) @ NLLSsolver /mnt/data/git/NLLSsolver.jl/src/optimize.jl:154
││││││┌ costgradhess!(linsystem::NLLSsolver.LinearSystem{…}, vars::Vector{…}, costs::NLLSsolver.VectorRepo{…}) @ NLLSsolver /mnt/data/git/NLLSsolver.jl/src/cost.jl:55
│││││││┌ kwcall(::@NamedTuple{…}, ::typeof(sum), fun::NLLSsolver.Bind{…}, vr::NLLSsolver.VectorRepo{…}) @ NLLSsolver /mnt/data/git/NLLSsolver.jl/src/VectorRepo.jl:54
││││││││┌ sum(fun::NLLSsolver.Bind{…}, vr::NLLSsolver.VectorRepo{…}; kw::@Kwargs{…}) @ NLLSsolver /mnt/data/git/NLLSsolver.jl/src/VectorRepo.jl:54
│││││││││┌ vrsum(fun::NLLSsolver.Bind{…}, vr::NLLSsolver.VectorRepo{…}, kw::@Kwargs{…}, T::Type{…}) @ NLLSsolver /mnt/data/git/NLLSsolver.jl/src/VectorRepo.jl:56
││││││││││┌ vrsum(fun::NLLSsolver.Bind{…}, kw::@Kwargs{…}, v::Vector{…}) @ NLLSsolver /mnt/data/git/NLLSsolver.jl/src/VectorRepo.jl:52
│││││││││││┌ kwcall(::@NamedTuple{…}, ::typeof(sum), f::NLLSsolver.Bind{…}, a::Vector{…}) @ Base ./reducedim.jl:980
││││││││││││┌ sum(f::NLLSsolver.Bind{…}, a::Vector{…}; dims::Colon, kw::@Kwargs{…}) @ Base ./reducedim.jl:980
│││││││││││││┌ kwcall(::@NamedTuple{…}, ::typeof(Base._sum), f::NLLSsolver.Bind{…}, a::Vector{…}, ::Colon) @ Base ./reducedim.jl:984
││││││││││││││┌ _sum(f::NLLSsolver.Bind{…}, a::Vector{…}, ::Colon; kw::@Kwargs{…}) @ Base ./reducedim.jl:984
│││││││││││││││┌ kwcall(::@NamedTuple{…}, ::typeof(mapreduce), f::NLLSsolver.Bind{…}, op::typeof(Base.add_sum), A::Vector{…}) @ Base ./reducedim.jl:326
││││││││││││││││┌ mapreduce(f::NLLSsolver.Bind{…}, op::typeof(Base.add_sum), A::Vector{…}; dims::Colon, init::Float64) @ Base ./reducedim.jl:326
│││││││││││││││││┌ _mapreduce_dim(f::NLLSsolver.Bind{…}, op::typeof(Base.add_sum), nt::Float64, A::Vector{…}, ::Colon) @ Base ./reducedim.jl:331
││││││││││││││││││┌ mapfoldl_impl(f::NLLSsolver.Bind{…}, op::typeof(Base.add_sum), nt::Float64, itr::Vector{…}) @ Base ./reduce.jl:36
│││││││││││││││││││┌ foldl_impl(op::Base.MappingRF{NLLSsolver.Bind{…}, Base.BottomRF{…}}, nt::Float64, itr::Vector{SimpleError2{…}}) @ Base ./reduce.jl:40
││││││││││││││││││││┌ _foldl_impl(op::Base.MappingRF{NLLSsolver.Bind{…}, Base.BottomRF{…}}, init::Float64, itr::Vector{SimpleError2{…}}) @ Base ./reduce.jl:50
│││││││││││││││││││││┌ (::Base.MappingRF{…})(acc::Float64, x::SimpleError2{…}) @ Base ./reduce.jl:92
││││││││││││││││││││││┌ (::NLLSsolver.Bind{typeof(NLLSsolver.costgradhess!), Tuple{…}})(args::SimpleError2{2, Float64, SVector{…}, SVector{…}}) @ NLLSsolver /mnt/data/git/NLLSsolver.jl/src/utils.jl:20
│││││││││││││││││││││││┌ costgradhess!(linsystem::NLLSsolver.LinearSystem{…}, vars::Vector{…}, cost::SimpleError2{…}) @ NLLSsolver /mnt/data/git/NLLSsolver.jl/src/cost.jl:52
││││││││││││││││││││││││┌ gradhesshelper!(linsystem::NLLSsolver.LinearSystem{…}, cost::SimpleError2{…}, vars::Tuple{…}, blockind::SVector{…}, varflags::Int64) @ NLLSsolver /mnt/data/git/NLLSsolver.jl/src/cost.jl:37
│││││││││││││││││││││││││┌ valuedispatch(lower::StaticInt{…}, upper::StaticInt{…}, val::Int64, fun::NLLSsolver.Bind{…}) @ NLLSsolver /mnt/data/git/NLLSsolver.jl/src/utils.jl:9
││││││││││││││││││││││││││┌ valuedispatch(lower::StaticInt{…}, upper::StaticInt{…}, val::Int64, fun::NLLSsolver.Bind{…}) @ NLLSsolver /mnt/data/git/NLLSsolver.jl/src/utils.jl:9
│││││││││││││││││││││││││││┌ valuedispatch(lower::StaticInt{…}, upper::StaticInt{…}, val::Int64, fun::NLLSsolver.Bind{…}) @ NLLSsolver /mnt/data/git/NLLSsolver.jl/src/utils.jl:5
││││││││││││││││││││││││││││┌ (::NLLSsolver.Bind{typeof(NLLSsolver.gradhesshelper!), Tuple{…}})(args::StaticInt{0}) @ NLLSsolver /mnt/data/git/NLLSsolver.jl/src/utils.jl:20
│││││││││││││││││││││││││││││┌ gradhesshelper!(linsystem::NLLSsolver.LinearSystem{…}, cost::SimpleError2{…}, vars::Tuple{…}, blockind::SVector{…}, varflags::StaticInt{…}) @ NLLSsolver /mnt/data/git/NLLSsolver.jl/src/cost.jl:25
││││││││││││││││││││││││││││││┌ computecost(residual::SimpleError2{2, Float64, SVector{…}, SVector{…}}, vars::Tuple{SVector{…}, SVector{…}}) @ NLLSsolver /mnt/data/git/NLLSsolver.jl/src/residual.jl:44
│││││││││││││││││││││││││││││││┌ computerescost(residual::SimpleError2{2, Float64, SVector{…}, SVector{…}}, kernel::NoRobust, vars::Tuple{Tuple{…}}) @ NLLSsolver /mnt/data/git/NLLSsolver.jl/src/residual.jl:51
││││││││││││││││││││││││││││││││ no matching method found `computeresidual(::SimpleError2{2, Float64, SVector{6, Float64}, SVector{3, Float64}}, ::Tuple{SVector{6, Float64}, SVector{3, Float64}})`: r = computeresidual(tuple(residual::SimpleError2{2, Float64, SVector{6, Float64}, SVector{3, Float64}})::Tuple{SimpleError2{2, Float64, SVector{6, Float64}, SVector{3, Float64}}}, vars::Tuple{Tuple{SVector{6, Float64}, SVector{3, Float64}}}...)
│││││││││││││││││││││││││││││││└────────────────────

My expectation in this case, and in other cases like this, is that since the parent struct is mutable, and the size of MMatrix is known at compile time, it will be inlined in the parent struct - though this may not even be relevant. It’s the parent struct that doesn’t escape. I’m expecting the compiler to understand that the parent struct doesn’t escape, and neither do any of its fields (and any subfields of them). But perhaps it’s not up to this.

I haven’t inlined the functions explicitly. I hope this isn’t a requirement, but haven’t tested it.

Oh, that’s a handy tool. That was indeed a bug. Thank you @matthias314 for finding it. I’ve fixed it in v4.0.6 of the package (just released), and added a unit test to catch it (if it reoccurs)! I now get:

julia> @report_call optimize!(problem, options, 4)
No errors detected

Sadly, however, it did not impact any of the allocations, all of which still exist.

Apparently so (v1.12.6), which is a bit surprising because I’ve heard of interprocedural escape analysis in Julia a while back:

julia> function neg!(ri)
         ri[] = -ri[]
       end
neg! (generic function with 1 method)

julia> function neg(i)
         ri = Ref(i)
         neg!(ri)
         ri[]
       end
neg (generic function with 1 method)

julia> @code_llvm neg(1) # automatic inlining even optimized away the RefValue
; Function Signature: neg(Int64)
;  @ REPL[27]:1 within `neg`
; Function Attrs: uwtable
define i64 @julia_neg_6659(i64 signext %"i::Int64") #0 {
top:
;  @ REPL[27]:3 within `neg`
; ┌ @ REPL[28]:2 within `neg!`
; │┌ @ int.jl:85 within `-`
    %0 = sub i64 0, %"i::Int64"
    ret i64 %0
; └└
}

julia> using AllocCheck; check_allocs(neg, (Int,))
Any[]

julia> function neg(i)
         ri = Ref(i)
         @noinline neg!(ri)
         ri[]
       end
neg (generic function with 1 method)

julia> check_allocs(neg, (Int,))
1-element Vector{Any}:
 Allocation of Base.RefValue{Int64} in .\refvalue.jl:8
  | RefValue{T}(x) where {T} = new(x)

Julia has an implementation of inter-procedural escape analysis but it isn’t hooked up to the compiler (it’s still fairly beta). If anyone wants a fairly complicated but cool project, reviving wip: overhaul EscapeAnalysis.jl - Pull Request #56849 - JuliaLang/julia - GitHub would be the path to turning it on by default (@aviatesk is pretty much full time working on Jetls and so hasn’t had time to finish it up)

I’ve just tried this, but it doesn’t seem to provide any more information than profiling allocations.

Yes! Me too.

Putting the mutable structs (and the vagaries of escape analysis) aside, there are two allocations for immutable structs that I find surprising:

  1. An instance of NLLSOptions, a concretely typed, 64B immutable struct, that’s passed down through the call stack as an input argument, but at some point gets popped into the heap.
  2. An instance of NLLSResult, a concretely typed, isbits, 112B immutable struct, that’s passed back up through the call stack as the sole return value, apparently on the heap.

There is nothing about either of these instances, or they way in which they’re used, that would lead me to imagine they could end up on the heap.

iterator::Type{T} in NLLSOptions may be a source? Type{T} is not a concrete type

Is it not concrete when T is concrete (which it is)? It makes the struct return isbits = false, but I have a patch that changes the type of the iterator field, such that isbits = true, and this makes no difference to the allocations.

it is not. see:

julia> isconcretetype(Type{Int})
false

julia> isbits(Type{Int})
false

OK. Thanks.

As I say, unfortunately fixing this doesn’t remove the allocation. All the allocations remain unchanged. So they remain a mystery.

The ::Type{T} field does appear to be contributing a pointer to a type or something, but whatever allocation contributed by instantiating options occurred prior to profiling. It doesn’t explain the Int64 allocation because it lacks such a field. When I tried AllocCheck.check_allocs(optimize!, typeof.((problem, options, 4))), it reported those 2 as well as a dynamic dispatch, all of the optimize! method at line 2 of optimize.jl. Unless something has changed since last year when I asked, dynamic dispatches require boxed inputs. If an input wasn’t already on the heap, the call needs to allocate and copy it to the heap first. That’s my working theory, but something that bothers me is the only visible Int64 input is 4, and I was also told that Julia cached small boxed Ints (-512:510 still incurs 0 allocations in the dynamic dispatch example I made then) so I don’t know why that showed up in runtime profiling. Maybe optimize! recurses to calling itself with a larger integer, maybe an “allocation” retrieving cached objects is still reported as one, but I’m baselessly speculating at this point.

I then tried JET.@report_opt optimize!(problem, options, 4), which spit out a giant report about recursion and dynamic dispatches. No idea where to start there, I do see recursion in your package in the course of optimize!, but the reported problems seems to start at a @warn at line 26 of cost.jl, not type inference issues elsewhere as I expected.

Aside, you don’t need the ::Type{T} field if all you’re doing is retrieving an associated type. A getter method can retrieve the parameter directly, and getproperty can wrap that in dot syntax.

Thanks. All of the dynamic dispatches disappear when the @warn line is commented out, and I get

julia> @report_opt optimize!(problem, options, 4)
No errors detected

Thanks. Perhaps I wasn’t calling check_allocs correctly when I tried earlier. Now I get

julia> check_allocs(optimize!, typeof.((problem, options, 4)))
3-element Vector{Any}:
 Allocation of NLLSOptions{:levenbergmarquardt, 1} in /Users/olly/.julia/dev/NLLSsolver.jl/src/optimize.jl:2
  | optimize!(problem::NLLSProblem, options::NLLSOptions, unfixed::Integer, callback=nullcallback) = setupsinglevarls(optimizeinternal!, problem, options, unfixed, Stats(), callback)

Stacktrace:
 [1] optimize!(problem::NLLSProblem{Union{SVector{6, Float64}, SVector{3, Float64}}, SimpleError2{2, Float64, SVector{6, Float64}, SVector{3, Float64}}}, options::NLLSOptions{:levenbergmarquardt, 1}, unfixed::Int64)
   @ NLLSsolver ~/.julia/dev/NLLSsolver.jl/src/optimize.jl:2

 Allocation of Int64 in /Users/olly/.julia/dev/NLLSsolver.jl/src/optimize.jl:2
  | optimize!(problem::NLLSProblem, options::NLLSOptions, unfixed::Integer, callback=nullcallback) = setupsinglevarls(optimizeinternal!, problem, options, unfixed, Stats(), callback)

Stacktrace:
 [1] optimize!(problem::NLLSProblem{Union{SVector{6, Float64}, SVector{3, Float64}}, SimpleError2{2, Float64, SVector{6, Float64}, SVector{3, Float64}}}, options::NLLSOptions{:levenbergmarquardt, 1}, unfixed::Int64)
   @ NLLSsolver ~/.julia/dev/NLLSsolver.jl/src/optimize.jl:2

 Dynamic dispatch to function optimize! in /Users/olly/.julia/dev/NLLSsolver.jl/src/optimize.jl:2
  | optimize!(problem::NLLSProblem, options::NLLSOptions, unfixed::Integer, callback=nullcallback) = setupsinglevarls(optimizeinternal!, problem, options, unfixed, Stats(), callback)

Stacktrace:
 [1] optimize!(problem::NLLSProblem{Union{SVector{6, Float64}, SVector{3, Float64}}, SimpleError2{2, Float64, SVector{6, Float64}, SVector{3, Float64}}}, options::NLLSOptions{:levenbergmarquardt, 1}, unfixed::Int64)
   @ NLLSsolver ~/.julia/dev/NLLSsolver.jl/src/optimize.jl:2

It shows two of the allocations the allocation profiler finds, and is also saying the initial call to optimize! is a dynamic dispatch. However, I think this is incorrect, because all the types are known, and @report_opt doesn’t flag it.

I’m fairly certain there aren’t any dynamic dispatches occurring, and I’m also sure that the NLLSOptions and NLLSResult instances being heap allocated are immutable, isbits structures. So their allocations remain a mystery to me.

Interesting, check_allocs and @report_opt appear to disagree on dynamic dispatches, and I can corroborate @report_opt passing when the @warn line is commented out. I’m not certain which is actually correct. Earlier I assumed optimize! was recursing to a dynamic dispatch of itself, but no, you’re right that it’s the initial call. I tried a pointless forwarding function w(problem, options, i) = @inline optimize!(problem, options, i), and check_allocs still thinks optimize! is dynamically dispatched (yet marked [inlined] in the stacktrace before w), so it’s not a generic initial call bug. Also worth mentioning that @code_llvm w(problem, options, 4) shows a call of optimize!, which is neither inlined nor the @ijl_apply_generic indicative of dynamic dispatch. Maybe worth an issue?

Aside, I also initially thought the dynamic dispatch would explain why check_allocs couldn’t statically catch all the other allocations, but that really depends on which static analysis tool is right.

Thanks for investigating. There is something suspicious there. However, I don’t know enough about Julia calls and llvm code to know exactly what isn’t working as expected, so I won’t raise any issues on that myself.

Interesting. I vaguely remember having seen something similar even with a @debug log message, which I believed to be free. Allocations that were unexplainable to be disappearing when commenting out the log statement. I don’t have a reproducer right now, but I’m very curious to see where this is going.

In this instance, no allocations disappear when the logging statement is commented out, since the log statement isn’t called, only compiled. What does disappear is the warnings of potential dynamic dispatches from the static analysis. But again, these dispatches would not have run since the logging statement was not hit. I don’t think there’s anything unexplainable in respect of the logging going on here. The warning statement should become free if I disable that level of logging during compilation, which would have the same effect as commenting it out.