Allocation during access to "typed" field of mutable struct

I have following struct

mutable struct Element{T<:AbstractElement} <: AbstractElement
    input::StringBuffer
    name::UnitRange{Int64}
    attributes::Union{Nothing, Attribute}
    value::Union{T, Nothing}
    parent::Union{Element, Nothing}
    next::Union{Element, Nothing}
end
Base.getindex(node::Element, key::AbstractString) = begin
    next = node.value
    while !isnothing(next)
        if _equals(getname(next), key) return next end
        next = getnext(next)
    end
    return error("no element with key: $key")
end

When I’m trying to access field name of the struct Element in function getindex using variable next there is 1 allocation (accessing any field except name does not cause allocations), but when I do it anywhere else it does not cause allocation. I don’t understand why accessing this field causes additional memory usage and how to fix this issue?

@code_warn for this function provides the following results:

Variables
  #self#::Core.Compiler.Const(getindex, false)
  node::Element{Element{Element}}
  key::String
  next::Union{Nothing, Element}

Body::Union{Nothing, Element}
1 ─       (next = Base.getproperty(node, :value))
2 ┄ %2  = Main.isnothing(next)::Bool
│   %3  = !%2::Bool
└──       goto #6 if not %3
3 ─       Base.getproperty(next, :name)
│   %6  = Main.getname(next)::StringView{UnsafeArray{UInt8,1}}
│   %7  = Main._equals(%6, key)::Bool
└──       goto #5 if not %7
4 ─       return next
5 ─       (next = Main.getnext(next))
└──       goto #2
6 ─ %12 = Base.string("no element with key: ", key)::String
│         Main.error(%12)
└──       Core.Compiler.Const(:(return %13), false)
1 Like

Please provide enough code so that the example is runnable for someone else.

2 Likes

As Kristoffer said, it’s basically impossible to help here without more code. I was able to modify your provided code in order to define the struct and I observe no allocations in accessing the name field:

julia> abstract type AbstractElement end

julia> mutable struct Element{T<:AbstractElement} <: AbstractElement
           input::String
           name::UnitRange{Int64}
           attributes::Union{Nothing, Int}
           value::Union{T, Nothing}
           parent::Union{Element, Nothing}
           next::Union{Element, Nothing}
       end

julia> let e = Element{Element}("hi", 1:10, nothing, nothing, nothing, nothing)
           @btime $e.name
       end
  1.299 ns (0 allocations: 0 bytes)
1:10

I suspect the allocations are caused by something else. What could be causing those allocations though is anybody’s guess, since nobody but you knows how you defined getname, getnext, _equals, or Attribute.

2 Likes

I’m sorry:

using Test
using BenchmarkTools

abstract type AbstractElement end

mutable struct Element{T<:AbstractElement} <: AbstractElement
           input::String
           name::UnitRange{Int64}
           attributes::Union{Nothing, Int}
           value::Union{T, Nothing}
           parent::Union{Element, Nothing}
           next::Union{Element, Nothing}
end

example(node::Element) = begin
    while !isnothing(node)
        if node.name == 1:1 return node end
        node = node.next
    end
    return error("error")
end

@testset "example" begin
    first = Element{Element}("", 2:2, nothing, nothing, nothing, nothing)
    second = Element{Element}("", 1:1, nothing, nothing, nothing, nothing)
    first.next = second
    @code_warntype example(first)
    @btime example($first)
end

You can just copypaste this snippet, it works and shows the problem

Ah, I see the problem now. This eluded me for a bit. The problem is that you’re using

::Union{Element, Nothing}

for the field next. This looks like a small union, but it’s actually not because Element is really Element{T} where {T}, a UnionAll!

Here’s a modified version of your code that doesn’t allocate and is two orders of magnitude faster:

abstract type AbstractElement end

mutable struct Element{T<:AbstractElement} <: AbstractElement
    input::String
    name::UnitRange{Int64}
    attributes::Union{Nothing, Int}
    value::Union{T, Nothing}
    parent::Union{Element{T}, Nothing}
    next::Union{Element{T}, Nothing}
end

example(node::Element) = begin
    while !isnothing(node)
        if node.name == 1:1 return node end
        node = node.next
    end
    return error("error")
end


first = Element{Element}("", 2:2, nothing, nothing, nothing, nothing)
second = Element{Element}("", 1:1, nothing, nothing, nothing, nothing)
first.next = second
@btime example($first)
#+RESULTS:
   3.110 ns (0 allocations: 0 bytes)
 Element{Element}("", 1:1, nothing, nothing, nothing, nothing)
7 Likes

Thanks!!!
Yeah, that was my misunderstanding of Julia typing. Switching from Java to Julia is a bit difficult))

So I actually didn’t need the extra information I requested earlier and if I was more perceptive, I could have noticed it right away. But having the code at least let me fiddle around until I stumbled upon the root problem

Yes, there can be lots of little performance gotchas like this when you get into type unstable code in Julia. I wish you the best of luck in the transition, I think you’ll find it’s worth your time!

How are you liking the language so far?

2 Likes

Actually, I would like to note 2 points: First, Julia allows write really high-performance code. Second, designing the code in Julia is a bit harder than in Java or Python due to absence of builtin interfaces. But, in general, it is very interesting language

3 Likes

Actually, there is a collection of informal interfaces in Julia. These are documented here.

3 Likes

I have a very similar issue that I think fits with what the OP is saying, so I will post here instead of starting a new thread (obviously feel free to tell me that I should move this to a new thread).

The problem has to do with seeing more allocations when accessing a function that is inside a struct than when using the same function directly. Please consider the following MWE. The function in question is tanh. In function first(), tanh is called directly. In function second() it is called from within struct dummy. Function third() is calls tanh from a struct where no type has been provided.

I hope the following code makes it clearer:

struct Dummy
  f::Function
end

struct DummyNoType
  f
end



function runme()

    a = randn(1000)

    dummy       = Dummy(tanh)
    dummynotype = DummyNoType(tanh)

    v = 0.0

    function first()
        for i in 1:1000
            v += tanh(a[i])
        end
    end

    function second()
        for i in 1:1000
            v += dummy.f(a[i])
        end
    end

    function third()
        for i in 1:1000
            v += dummynotype.f(a[i])
        end
    end

    @time first()
    @time second()
    @time third()
end

If execute run() I get:

  0.000057 seconds (2.00 k allocations: 31.250 KiB) # called first()
  0.000081 seconds (3.00 k allocations: 46.875 KiB) # called second()
  0.000089 seconds (3.00 k allocations: 46.875 KiB) # called third()

Why does the call to second() result in more memory allocations? Thanks for reading this.

Function is an abstract type. Use

struct Dummy{F}
  f::F
end
1 Like

That is the solution, thanks!