Question Julia best practices for structs of numbers

Hello. As the title suggests, I have a struct of the form:

struct Foo
    arr
    int
end

Of course, then:

myFoo = Foo(rand(5), 2)
myFoo.int = 3

This code errors. However, I can still do myFoo.arr .= rand(5). Suppose I want to have the possibility of changing int. Also, suppose that I really care about performance. I came up with two options, both of which seem suboptimal.

Option 1:

mutable struct Foo2
    const arr
    int
end
myFoo2 = Foo2(rand(5), 2)

Why I don’t like it: It declares a mutable struct, which might lead to possible performance loss.

Option 2:

myFoo3 = Foo(rand(5), Ref(2))

Why I don’t like it: The syntax myFoo3.int[] is ugly and hard to read.

For reference:

using BenchmarkTools

@btime for i in 1:10000
    myFoo2.int = i
end
# 937.456 μs (28467 allocations: 444.80 KiB)

@btime for i in 1:10000
    myFoo3.int[] = i
end
# 573.724 μs (9489 allocations: 148.27 KiB)

So, the second option seems much more performative. Am I missing anything?

There seems to be a contradiction here. You want to mutate the object, but you don’t want it to be mutable? The reason immutable objects can be faster is exactly because they are guaranteed to not be mutated.

You can mutate an array that is contained in an immutable struct because you are not really mutating the parent object.

If you care about performance, you should do two things, add types to the struct fields, and don’t do benchmarking in global scope.

mutable struct Foo2
    const arr::Vector{Float64}
    int::Int
end

function mybench(N)
    myFoo2 = Foo2(rand(5), 2)
    for i in 1:N
        myFoo2.int = i
    end
    return myFoo2
end

@btime mybench(10_000)
4 Likes

The above said, I don´t think you’ll get a measurable performance difference by declaring const an array, which is by itself mutable. The only thing constant there is the reference to the array, in this case.

Just keep the struct immutable and use Accessors:

struct Foo{U,V}
    arr::U
    int::V
end

myFoo = Foo(rand(5), 2)

using Accessors

function mybench(myFoo)
    for i in 1:10_000
        @reset myFoo.int = i
    end
    myFoo
end

@btime mybench($myFoo)

As noted by @DNF already, having concretely typed fields in the struct is most important for performance!

2 Likes

Thank you all for replying. Of course I should benchmark outside of global scope!

Just a follow up. If I expect for int to change quite often, would it then be better to declare a mutable struct? Is there no difference in that case?

Also @bertschi the @reset macro seems to make a copy so it would be quite wasteful (and would require writing myFoo = @reset myFoo.int = i per the documentation.

Yes I know its a bit of an odd request to care about performance while mutating data. Its just that I don’t like to have different templates if I have only Arrays Vs if I need to add a number to them for whichever reason.

Thanks a lot you were super helpful!

1 Like

Yes, it does make a (shallow) copy, but immutable structs can usually be stack allocated so this is very cheap and can even save allocations compared to mutable structs.
Further, @reset myFoo.int = i expands into myFoo = @set myFoo.int = i, so you don’t need to reassign myFoo.

2 Likes

If you expect a field to change, the natural solution is to make the struct mutable. But sometimes, for small structs you should consider just making a new object. It depends on how you need to use them, whether mutation is required, or just an option.

Oh, not at all! Often mutation is more efficient. Mutating an existing array is normally more efficient than allocating a new array, and even more so for large arrays. Mutating is common in performance oriented code.

2 Likes

If the only problem is the “ugly syntax”, then you could use the following approach of redefining getproperty and setproperty!. Also, I recommend parameterizing your fields.

julia> struct Foo{A,I}
           arr::A
           int::I
       end

julia> foo = Foo(rand(5), Ref(2))
Foo{Vector{Float64}, Base.RefValue{Int64}}([0.5315736776442587, 0.36287874566069844, 0.6723523160756516, 0.07312792795028544, 0.46898940828968805], Base.RefValue{Int64}(2))

julia> function Base.getproperty(foo::Foo{<:Any, <: Base.RefValue}, s::Symbol)
           if s == :int
               return getfield(foo, :int)[]
           end
           return getfield(foo, s)
       end

julia> function Base.setproperty!(foo::Foo{<:Any, <: Base.RefValue}, s::Symbol, v)
           if s == :int
               return getfield(foo, :int)[] = v
           end
           return setfield!(foo, s, v)
       end

julia> foo = Foo(rand(5), Ref(2))
Foo{Vector{Float64}, Base.RefValue{Int64}}([0.7603341321695618, 0.006355782041778335, 0.436192674476951, 0.9048628450681871, 0.828408074058733], Base.RefValue{Int64}(2))

julia> foo.int = 5
5

julia> foo.int
5

julia> foo.int = 3
3

julia> foo.int
3

julia> ismutable(foo)
false
1 Like