Structs: mutable versus immutable

Hi, I struggle to understand the behavior of the following code:

struct TestStruct
    a::Int64
    b::Array{Float64,1}
end
function test_function!(s)
    s=TestStruct(2,s.b)
end
a=TestStruct(1,rand(1_000))
test_function!(a)
a

Why is a not updated to a.a=2?

structs in Julia are not mutable (for that, you want a mutable struct. Also, Julia uses Pass By Sharing, so test_function takes an s, creates a new s and returns it. Assuming TestStruct was mutable, you would want. s.a = 2 which changes the value of s.a instead of creating a new object for s

Yes, I know. Unfortunately, the mutable struct is very slow. Let me reformulate my question: Can I mutate only array in Julia via function? The problem really is that mutable struct is slow and if I want to mutate some pack of data and do not want to use return because it is additional cost.

Can you give an example that shows what you want in context?

To “modify” a field in a struct, you can write

a = modify_field(a)

If there’s an array of as, then you can use

map!(modify_field, array, array)

to modify the array in-place, assuming the transformation to be done depends only on one individual value.

Thanks, I will check it out.

I just remembered that there is also jw3126 / Setfield.jl package.

Setfield is nice, but no faster than doing it manually, it’s just more convenient notation.

Mutable structs should not be ‘very slow’, maybe not quite as fast as an immutable struct, but if it’s ‘very slow’, I suspect something else is wrong.

Anyway, if you are mutating the array inside the struct, that is only possible because the array itself is mutable.

2 Likes

Anyway, if you are mutating the array inside the struct, that is only possible because the array itself is mutable.

Yes, that is clear.

Regarding the speed:

mutable struct MutableS
    a::Int
    b::Array{Float64,1}
end
struct S
    a::Int
    b::Array{Float64,1}
end

function test_ms(val)
    for i in 1:1_000_000
        val.a = i
    end
end

function test_s(val)
    for i in 1:1_000_000
        @set val.a = i
    end
end

ms=MutableS(1,rand(1_000));
s=S(1,rand(1_000));

@benchmark test_ms(ms)
BenchmarkTools.Trial: 
  memory estimate:  0 bytes
  allocs estimate:  0
  --------------
  minimum time:     313.262 μs (0.00% GC)
  median time:      344.536 μs (0.00% GC)
  mean time:        409.776 μs (0.00% GC)
  maximum time:     826.716 μs (0.00% GC)
  --------------
  samples:          10000
  evals/sample:     1

@benchmark test_s(s)
BenchmarkTools.Trial: 
  memory estimate:  0 bytes
  allocs estimate:  0
  --------------
  minimum time:     17.570 ns (0.00% GC)
  median time:      19.179 ns (0.00% GC)
  mean time:        23.652 ns (0.00% GC)
  maximum time:     485.720 ns (0.00% GC)
  --------------
  samples:          10000
  evals/sample:     998

It is quite a difference. Not sure if I am doing something wrong with mutable structs but it seems to be pretty clear how to use them.

This is a benchmarking problem. The mutable struct gives you a million updates in 400 μs. That’s 0.4 ns per update, corresponding to 1 update per clock cycle on a 2.5 GHz processor. Those who know more details about CPU architecture can say whether this is good or bad but at least it’s in the right ballpark and can hardly count as slow.

The 24 ns for a million supposed updates of an immutable struct is, on the other hand, completely unreasonable. Nothing happens that fast on a CPU. With all certainty the compiler has been smart enough to see that the code doesn’t actually produce anything and has optimized away nearly everything.

11 Likes

And yet that is one reason to prefer immutable structs :slightly_smiling_face:

1 Like

Well, in this made-up example, yes… but we should put that in the actual context to see if immutable structs are the answer to the original problem.

That benchmark is highly artificial and gives a false impression. It also shows why you should not write loops like that (artificial loops added for timing purposes) into benchmarks. BenchmarkTools already does that for you.

In fact, this function does zero work, because it doesn’t even return the new value, so it can just be removed completely: Sorry, this was wrong. It does actually return the last value in the for loop.

1 Like

The actual context is finite element grid. I am working on computational fluid dynamics project with fluid-structure interaction capability, i.e., moving grid, i.e., mutating data in struct. I was trying to find the right way to store data.

I tried to do a better benchmark here. Not sure if it is completely correct, but the results seem reasonable.

I changed some of the names, for various reasons.

mutable struct MutableS
    a::Int
    b::Array{Float64,1}
end

struct SS
    a::Int
    b::Array{Float64,1}
end

test_ms!(s, val) = (s.a = val)
test_s(s, val) = (@set s.a = val; return s)

ms = MutableS(1, rand(1_000));
s = SS(1, rand(1_000));

Benchmark:

julia> @benchmark test_ms!($(Ref(ms))[], val) setup=(val=rand(0:9))
BenchmarkTools.Trial: 
  memory estimate:  0 bytes
  allocs estimate:  0
  --------------
  minimum time:     1.199 ns (0.00% GC)
  median time:      1.400 ns (0.00% GC)
  mean time:        1.386 ns (0.00% GC)
  maximum time:     26.001 ns (0.00% GC)
  --------------
  samples:          10000
  evals/sample:     1000

julia> @benchmark test_s($(Ref(s))[], val) setup=(val=rand(0:9))
BenchmarkTools.Trial: 
  memory estimate:  0 bytes
  allocs estimate:  0
  --------------
  minimum time:     1.199 ns (0.00% GC)
  median time:      1.300 ns (0.00% GC)
  mean time:        1.335 ns (0.00% GC)
  maximum time:     43.401 ns (0.00% GC)
  --------------
  samples:          10000
  evals/sample:     1000

It’s a bit difficult to benchmark so small function.

1 Like

Can I say that I really dislike this implicit return behaviour? I wish explicit return statements were required, at least for “long-form functions”.

Thanks, I will check BenchmarkTools.jl. I probably overthinking the problem anyway. I will stick with mutable struct for larger collections of data and struct for smaller, e.g. Point.

Sure, sorry, I just wanted to point out that the compiler will in general be able to do more optimizations with immutable types.

Concerning the original post:

A natural way to obtain what you originally wanted here is simply to return a copy of the value instead of trying to modify it:

julia> function test_function(s)
          s2 = TestStruct(2,s.b)
          return s2
       end
test_function (generic function with 1 method)

julia> a = TestStruct(1,rand(1_000));

julia> a = test_function(a)
TestStruct(2, ... 

You might think: But then I am just copying the struct! Indeed you are, but this is fast and allocation free (edit: actually not true if the field itself is mutable, which is the case here), because immutable structs are generally stored in the fast stack memory. This is no different from doing

x = 1
x = 2*x

except that your struct has more fields than x.

I understand that @set does no miracle here, it just conveniently does that copy without one having to define the structure explicitly. And there is no gain in performance:

julia> function change_vec1!(vec)
         for s in vec
           s = TestStruct(2*s.a,s.b)
         end
       end
change_vec1! (generic function with 1 method)

julia> function change_vec2!(vec)
         for s in vec
           @set s.a = 2*s.a
         end
       end
change_vec2! (generic function with 1 method)

julia> vec = [ TestStruct(rand(1:10),rand(1_000)) for i in 1:10_000 ];

julia> @btime change_vec1!(vec)
  5.036 μs (0 allocations: 0 bytes)

julia> vec = [ TestStruct(rand(1:10),rand(1_000)) for i in 1:10_000 ];

julia> @btime change_vec2!(vec)
  5.081 μs (0 allocations: 0 bytes)


edit: these benchmarks are wrong as pointed below. (it is true that @set is the same as copying the struct, but in these cases the vector of structs is not being modified).

2 Likes

A natural way to obtain what you originally wanted here is simply to return a copy of the value instead of trying to modify it:

That was my idea also but there is additional cost for returning the value out of the function.

Also it seems that you have actually write

s = @set s.a=1

to update value inside of s.

Anyway, as I wrote above I was probably overthinking the problem.

Note that this does not update the value of s, it in principle copies the entire s (which is immutable). Still, that is still faster, than mutating the value a of an equivalent mutable struct (and this is because the way mutable and immutable structs are stored in memory):

julia> mutable struct MutableTestStruct
           a::Int64
           b::Array{Float64,1}
       end

julia> function change_vec3!(vec)
         for s in vec
           s.a = 2*s.a
         end
       end
change_vec3! (generic function with 1 method)

julia> vec = [ MutableTestStruct(rand(1:10),rand(1_000)) for i in 1:10_000 ];

julia> @btime change_vec3!($vec)
  8.692 μs (0 allocations: 0 bytes)

I said above in principle copies, because if you benchmark those toy examples, you will see that the time is independent of the size of the b field, thus clearly that field is not being copied (using @set or copying the struct explicitly). The compiler will figure out what is the best way to deal with such an array, probably.

Are you sure about this, that it’s not just another benchmarking artefact? Or what are you referring to?