I understand Julia not being fast when using structs of abstract types and I know that there is no such thing as singleton abstract types. However I would hope that it would be different when using Abstract types of which all concrete implementations are empty singletons.
Below a benchmark example:
using BenchmarkTools
abstract type AbstractType end
struct ConcreteSingleton1 <: AbstractType end
struct ConcreteSingleton2 <: AbstractType end
struct ConcreteSingleton3 <: AbstractType end
mutable struct MyStruct1
x :: Int
consin :: AbstractType
end
mutable struct MyStruct2
x :: Int
consin :: Type
end
mutable struct MyStruct3
x :: Int
consin :: ConcreteSingleton1
end
#sum ints
function f(vs)
vsum = 0
for v in vs
vsum += v.x + fs(v.consin)
end
return vsum
end
fs(a) = 0
fs(cs1::ConcreteSingleton1) = 1
fs(cs1::ConcreteSingleton2) = 2
fs(cs1::ConcreteSingleton3) = 3
fs(cs1::Type{ConcreteSingleton1}) = 1
fs(cs1::Type{ConcreteSingleton2}) = 2
fs(cs1::Type{ConcreteSingleton3}) = 3
mystructs1 = fill(MyStruct1(1, ConcreteSingleton1()), 1000);
mystructs2 = fill(MyStruct2(1, ConcreteSingleton1), 1000);
mystructs3 = fill(MyStruct3(1, ConcreteSingleton1()), 1000);
#46.356 μs (745 allocations: 11.64 KiB)
@btime f(mystructs1)
#133.727 μs (745 allocations: 11.64 KiB)
@btime f(mystructs2)
#596.994 ns (1 allocation: 16 bytes)
@btime f(mystructs3)
this shows that the compiler cannot tell apart Abstract types from their subtypes. Is there a way to tell the compiler (or to make the compiler understand) that all AbstractType (or the field MyStruct.consin) is always going to be an empty singleton ?
This way it can preallocate memory like in the case of a concrete type.
Maybe my head is stack on thinking in this OOP schema of abstract types belonging to classes. Feel free to suggest a different design, if this efficiency issue can be handled.
P.S. (1)
I also tried using Type in the hope of showing to the compiler that this field’s memory usage is rather static, but didn’t work out.
P.S. (2)
Please don’t suggest any solutions involving union splitting as I would like that to be easily extandable and taking advantage of the AbstractType umbrella
P.S. (3)
Substituting AbstractType with a @enum could actually be done but it would lead to ugly if-else code later.
using BenchmarkTools
abstract type AbstractType end
struct ConcreteSingleton1 <: AbstractType end
struct ConcreteSingleton2 <: AbstractType end
struct ConcreteSingleton3 <: AbstractType end
mutable struct MyStruct1
x :: Int
consin :: AbstractType
end
mutable struct MyStruct2
x :: Int
consin :: Type
end
mutable struct MyStruct3
x :: Int
consin :: ConcreteSingleton1
end
mutable struct MyStruct4{A<:AbstractType}
x :: Int
consin :: A
end
#sum ints
function f(vs)
vsum = 0
for v in vs
vsum += v.x + fs(v.consin)
end
return vsum
end
fs(a) = 0
fs(cs1::ConcreteSingleton1) = 1
fs(cs1::ConcreteSingleton2) = 2
fs(cs1::ConcreteSingleton3) = 3
fs(cs1::Type{ConcreteSingleton1}) = 1
fs(cs1::Type{ConcreteSingleton2}) = 2
fs(cs1::Type{ConcreteSingleton3}) = 3
mystructs1 = fill(MyStruct1(1, ConcreteSingleton1()), 1000);
mystructs2 = fill(MyStruct2(1, ConcreteSingleton1), 1000);
mystructs3 = fill(MyStruct3(1, ConcreteSingleton1()), 1000);
mystructs4 = fill(MyStruct4(1, ConcreteSingleton1()), 1000);
@btime f(mystructs1)
@btime f(mystructs2)
@btime f(mystructs3)
@btime f(mystructs4)
sorry but not really… because then although mutable I cannot change the type to something else from what it initialized.
julia> mystructs4[1].consin = ConcreteSingleton2()
ERROR: MethodError: Cannot `convert` an object of type ConcreteSingleton2 to an object of type ConcreteSingleton1
Closest candidates are:
convert(::Type{T}, ::T) where T at ~/Downloads/Apps/julia/julia-1.7.2/share/julia/base/essentials.jl:218
Stacktrace:
[1] setproperty!(x::MyStruct4{ConcreteSingleton1}, f::Symbol, v::ConcreteSingleton2)
@ Base ./Base.jl:43
[2] top-level scope
@ REPL[29]:1
using BenchmarkTools
abstract type AbstractType end
struct ConcreteSingleton1 <: AbstractType end
struct ConcreteSingleton2 <: AbstractType end
struct ConcreteSingleton3 <: AbstractType end
mutable struct MyStruct1
x :: Int
consin :: AbstractType
end
mutable struct MyStruct2
x :: Int
consin :: Type
end
mutable struct MyStruct3
x :: Int
consin :: ConcreteSingleton1
end
#sum ints
function f(vs)
vsum = 0
for v in vs
if v.consin isa ConcreteSingleton1
cs1 = v.consin
vsum += v.x + fs(cs1)
elseif v.consin isa ConcreteSingleton2
cs2 = v.consin
vsum += v.x + fs(cs2)
elseif v.consin isa ConcreteSingleton3
cs3 = v.consin
vsum += v.x + fs(cs3)
else
@assert false
end
end
return vsum
end
function f(vs::Vector{MyStruct2})
vsum = 0
for v in vs
if v.consin isa Type{ConcreteSingleton1}
vsum += v.x + fs(ConcreteSingleton1)
elseif v.consin isa Type{ConcreteSingleton2}
vsum += v.x + fs(ConcreteSingleton2)
elseif v.consin isa Type{ConcreteSingleton3}
vsum += v.x + fs(ConcreteSingleton3)
else
@assert false
end
end
return vsum
end
fs(a) = 0
fs(cs1::ConcreteSingleton1) = 1
fs(cs1::ConcreteSingleton2) = 2
fs(cs1::ConcreteSingleton3) = 3
fs(cs1::Type{ConcreteSingleton1}) = 1
fs(cs1::Type{ConcreteSingleton2}) = 2
fs(cs1::Type{ConcreteSingleton3}) = 3
mystructs1 = fill(MyStruct1(1, ConcreteSingleton1()), 1000);
mystructs2 = fill(MyStruct2(1, ConcreteSingleton1), 1000);
mystructs3 = fill(MyStruct3(1, ConcreteSingleton1()), 1000);
@btime f(mystructs1)
@btime f(mystructs2)
@btime f(mystructs3)
For this specific case, why you do not use enumeration? Do you expect users to extend your abstract type?
Perceive that the cost is inescapable, if you used an @Enum or an Int directly to distinguish between the singleton types, then when you wanted to dispatch on these values you would need to wrap them in Val(value). So any code that wants to dispatch on a a field that may store different types (disguised or not) needs to pay the price of type instability. The only ways out I see are:
Do not have a single struct with a field that can hold multiple distinct types, have a parametrized struct instead. This is, use mutable struct MyStruct1{consin <: AbstractType}; x :: Int; end. Dispatch on the type parameter, that will be type stable because it is always the same for the same instance of your parametric type.
Use an implicit or explicit enumeration and then Val with the ValSplit package.
That is very interesting.
I can see that there is some complexity with this if-else’s that need to be introduced in several parts of the code but if this is handled from ValSplit it would be a nice workaround.
No I don’t expect that. I specifically wanted types in order to leverage multiple dispatch.
E.g. having as a state machine a function
With enumeration I need to deploy if-elses which makes the code harder to read.
So it seems that the solution would be to dispatch on a Val, and ValSplit.jl has a way to automatize it in order to look like multiple dispatch although it’s if-elses.
So also to answer my question, it’s not latency due to the compiler not knowing how much memory to allocate, rather latency due to multiple dispatch needing to happen on execution time being slower (due to abstract types).