Struct of singleton Abstract types performance

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.

The use case was to incorporate a state machine to my struct similarly to
this presentation JuliaCon 2019 | Implementing State Machines Simply using Multiple Dispatch | Joshua Ballanco - YouTube.
Hence that is why mutability is needed.

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.

Thanks!

I tried ‘concretizing’ the struct

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)

yielding

  43.600 μs (745 allocations: 11.64 KiB)
  126.500 μs (745 allocations: 11.64 KiB)
  521.466 ns (1 allocation: 16 bytes)
  520.419 ns (1 allocation: 16 bytes)

Does this help?

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


1 Like

Next trial;)

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)

yielding

  45.100 μs (745 allocations: 11.64 KiB)
  984.615 ns (1 allocation: 16 bytes)
  521.466 ns (1 allocation: 16 bytes)

Inspired by this announcement, still not sure if this helps…

Edit: fixing significant mistakes…

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:

  1. 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.
  2. 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

step(state::ConcreteSingleton1, transition::Transition1) = ...

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).

for complete reference I give the ValSplit code here

using BenchmarkTools
using ValSplit

@enum AbstractEnum concretenum1 concretenum2 concretenum3

mutable struct MyStructVS
    x :: Int
    consin :: AbstractEnum
end

fs(cs::Val{concretenum1}) = 1
fs(cs::Val{concretenum2}) = 2
fs(cs::Val{concretenum3}) = 3
@valsplit fs(Val(ae::AbstractEnum)) = 0

function f(vs)
    vsum = 0
    for v in vs
        vsum += v.x + fs(v.consin)
    end
    return vsum
end

mystructs1 = fill(MyStructVS(1, concretenum1), 1000);

# 692.034 ns (1 allocation: 16 bytes)
@btime f(mystructs1)

achieving near-ConcreteType times (just 100ns more)

3 Likes