Mutable struct without fields behaving weirdly

struct foo end
mutable struct bar end
bar()==bar()
#true
bar()===bar()
#true
isbits(bar)
#false
A=Vector{Tuple{bar, Int}}(uninitialized, 1)
#1-element Array{Tuple{bar,Int64},1}:
 #undef
A=Vector{Tuple{foo, Int}}(uninitialized, 1)
#1-element Array{Tuple{foo,Int64},1}:
# (foo(), 140167614418144)

What did I expect? Either disallow mutable struct without fields; or make it synonymous to the immutable version; or, much more interesting, make it so that each bar() is distinct under ===. So each call to bar() would basically create a new object id; old IDs can be copied, but IDs will never collide.

With the current behavior, I would think that empty mutable structs make no sense at all.

This is called a “singleton” type. What you are seeing is probably a holdover performance optimization from the early days when Julia did not have immutable types, so “mutable” singletons had to be fast.

@vtjnash similarly suggested that mutable singletons should allocate unique objects in: https://github.com/JuliaLang/julia/issues/17149
but no one has gotten around to working on it.

1 Like

Thanks! I apologize for failing yet again at finding the answer in the issue tracker.

Additional bonus for the unique objects: They can have finalizers.

Don’t worry, GitHub issue search is notoriously hard. This question led to a good discussion on today’s triage call and a decision to change this behavior. There’s a historical reason why it is the way it is: having zero-size singleton types is really useful, and we originally didn’t have immutable struct types, so the obvious approach was for zero-field structs to be singletons. Now that we’ve got mutable and immutable structs, it makes sense for the mutable ones to be unique and the immutable ones to be singletons.

1 Like

This has now been changed on master:

https://github.com/JuliaLang/julia/pull/25854

Nice! :+1:

In this way empty mutable structs can be used as unique IDs?

Yes, exactly. Immutable structs with no fields are still singletons.

Wow, that’s interesting! In a package of mines I’m generating random numbers to get unique identifiers, but this seems a much better solution.

I have to understand if I need to ever visualize the ID: a number is much more human-readable, and it’s pretty hard to distinguish between Foo() and Foo(), only Julia knows they’re different guys. If I don’t need to show the ID to the user, this would be perfect.

I think you could just show pointer_from_objref for display (the pointer can be reused only after the old object has been reclaimed, so this preserves identity for objects with overlapping lifetimes). But since the new mutable empty struct is afaik not bitstype you might need to take care about possible performance losses compared to just a 128 bit random number (or whatever kind of crypto nonce you could use). But they can carry finalizers, so yay!

Large part of my enthusiasm was due to the fact that in Julia 0.6 creating and comparing empty mutable structs is very fast:

julia> using BenchmarkTools

julia> mutable struct Foo end

julia> @benchmark Foo()
BenchmarkTools.Trial: 
  memory estimate:  0 bytes
  allocs estimate:  0
  --------------
  minimum time:     1.491 ns (0.00% GC)
  median time:      1.782 ns (0.00% GC)
  mean time:        1.759 ns (0.00% GC)
  maximum time:     19.624 ns (0.00% GC)
  --------------
  samples:          10000
  evals/sample:     1000

julia> a = Foo(); b = Foo();

julia> @benchmark $a == $b
BenchmarkTools.Trial: 
  memory estimate:  0 bytes
  allocs estimate:  0
  --------------
  minimum time:     0.014 ns (0.00% GC)
  median time:      0.021 ns (0.00% GC)
  mean time:        0.022 ns (0.00% GC)
  maximum time:     0.047 ns (0.00% GC)
  --------------
  samples:          10000
  evals/sample:     1000

However now on master the situation is much different:

julia> using BenchmarkTools

julia> mutable struct Foo end

julia> @benchmark Foo()
BenchmarkTools.Trial: 
  memory estimate:  8 bytes
  allocs estimate:  1
  --------------
  minimum time:     4.452 ns (0.00% GC)
  median time:      6.701 ns (0.00% GC)
  mean time:        8.447 ns (25.23% GC)
  maximum time:     20.320 ÎĽs (99.96% GC)
  --------------
  samples:          10000
  evals/sample:     1000

julia> a = Foo(); b = Foo();

julia> @benchmark $a == $b
BenchmarkTools.Trial: 
  memory estimate:  0 bytes
  allocs estimate:  0
  --------------
  minimum time:     1.492 ns (0.00% GC)
  median time:      1.499 ns (0.00% GC)
  mean time:        1.538 ns (0.00% GC)
  maximum time:     28.399 ns (0.00% GC)
  --------------
  samples:          10000
  evals/sample:     1000

making the difference with random numbers negligible.

does it make a difference if you run your benchmark within a function, rather than in global scope?

Disclaimer: I haven’t tested this yet, not slacking off, code is compiling.

Pre this update, comparing empty structs (mutable or not) is a NOP if types are inferred (it is literally free and the answer is, they are equal if and only if they have the same type). Post this update, comparing empty immutable structs is still free, and comparing empty mutable structs is exactly as expensive as comparing a pointer or an Int64.

You can maybe expect a speedup for comparison over e.g. 256 bit nonces if you are very paranoid about collisions of random numbers.

Pre this update, mutable empty structs made no sense at all (immutable empty structs were always better) and were a historical leftover. Post this update, mutable empty structs now serve a function different from immutable empty structs, and there was nothing before serving this new function (nonce). GC controlled and finalizable nonces are kinda niche, but this update looks “more coherent” in the sense that default equality for user-defined mutable objects never looks at the fields and only compares pointers; except for the former special case of mutable empty structs.

If your code on 0.6 or 0.7 used mutable empty structs before this update then your code was sub-optimal (could be improved by switching to the semantically equivalent but sometimes more performant immutable variant, because the old mutable empty struct infected composites with non-bitstype-ness for no conceivable upside).

Note that the new mutable empty struct allocates.

Also, previously the comparison was a no-op and now it’s an op. Does that factor into what you’re seeing? The compiler is pretty good at just not doing things that it knows aren’t necessary.

Not at all. I used interpolation just to avoid problems with global scope. As suggested by foobar_lv2 and StefanKarpinski the key here is that operations with empty mutable structs were no-op before.

No, as I told above I’m currently using plain numbers.

Yes, noted.