What are some approaches to define sentinel values in immutables without introducing type instabilities? Am asking for DynamicExpressions.jl to try to reduce allocations in the branch allowing n-degree nodes.
Some context: I try to avoid Union{T,Nothing}
for possibly-defined values in immutables, because of the potential for cascading type instabilities when union splitting fails. I have adopted a few patterns to avoid this, one being to explicitly store a field that marks a value as defined, like:
struct A{T}
x::T
isdefined::Bool
end
I will then explicitly check a.isdefined
before accessing x
. While a little bit less convenient, this seems to be same speed as an good compiler-inlined union splitting, while saving us from the potential cascade of type instabilities. Since, if not careful, it is really easy to get hit by a performance problem using unions:
julia> struct B{T}
x::NTuple{3,Union{T,Nothing}}
end
julia> # Sum over Vector{B{T}}
bar(bs) = sum(bs) do b
@something(b.x[1], 0.0) + @something(b.x[2], 0.0) + @something(b.x[3], 0.0)
end;
julia> @btime bar(bs) setup=(bs = [B{Float64}(ntuple(_ -> rand(Bool) ? randn() : nothing, 3)) for _ in 1:100_000]);
4.688 ms (149141 allocations: 2.28 MiB)
Compare this to the isdefined
version:
julia> struct A{T}
x::NTuple{3,T}
isdefined::NTuple{3,Bool}
end
julia> # Sum over Vector{A{T}}
foo(as) = sum(as) do a
(a.isdefined[1] ? a.x[1] : 0.0) + (a.isdefined[2] ? a.x[2] : 0.0) + (a.isdefined[3] ? a.x[3] : 0.0)
end;
julia> @btime foo(as) setup=(as = [A((randn(3)...,), (rand(Bool, 3)...,)) for _ in 1:100_000]);
94.708 ÎĽs (0 allocations: 0 bytes)
So it is about a 50x difference just based on using an explicit mask field.
The main issue with this is that I don’t know how to generically initialise “undefined” values in immutables. How should one define that for arbitrary types? This can be done in in mutables as you can just leave it #undef
. But, what about immutables?
For example, take a recursive struct with children held in a tuple (the tuple being the immutable of interest):
# Defines a tree with up to 3 children
mutable struct Node{T}
x::T
children::NTuple{3,Node{T}}
isdefined::NTuple{3,Bool}
Node{T}() where {T} = new{T}()
Node(x::T, c::NTuple{3,Node{T}}, def::NTuple{3,Bool}) where {T} = new{T}(x, c, def)
end
How would we initialise only some of the fields in the children
tuple? I don’t want to make an empty Node()
– that’s too expensive. And I don’t want to use Union{Node{T},Nothing}
for the reasons above. So… what can we do?
One approach I have started to do is set up “poisoned” nodes that will not add allocations, but still result in some sort of downstream error. The simplest is to create a cycle - which will hopefully cause a stack overflow for improper accesses in a downstream code:
node = Node{Float64}()
node.x = 0.5
node.isdefined = (true, false, false)
# What to do for undefined `node.children`? Create a cycle!
node.children = (Node{Float64}(), node, node)
Now, anybody who tries to recurse through the children without checking .isdefined
, they will get a stack overflow error. The bug stays loud and we don’t add any allocations, and this is generic across T
.
However, it feels hacky to do this. And a new user might be confused by the specific error.
Are there other approaches to solve this? (I just wish there was a way to use #undef
within an immutable.)