Unexpected type instability with getproperty()

I have a data type that stores a vector z and a list of views to that vector, whose index ranges can be specified by the user, here a and b. The views are also accessible via m.a and m.b by overloading getproperty.

Depending on how and where I create those views getproperty is type stable or not. Please see the following MWE:

import Base.hasproperty, Base.getproperty

struct MyStruct{T, ZT <: AbstractVector{T}, VT <: NamedTuple}
    z::ZT
    v::VT

    # function MyStruct(z::ZT, vars) where {ZT}
    #     # this is type-stable
    #     v = ( a = view(z, 1:2), b = view(z, 3) )

    #     # this is not type-stable
    #     # v = NamedTuple{keys(vars)}(( view(z, idx_range) for idx_range in vars ))

    #     new{eltype(z), ZT, typeof(v)}(z, v)
    # end
end

function MyStruct(z::ZT, vars) where {ZT}
    # this is type-stable
    # v = ( a = view(z, 1:2), b = view(z, 3) )

    # this is also type-stable
    v = NamedTuple{keys(vars)}(( view(z, idx_range) for idx_range in vars ))

    MyStruct{eltype(z), ZT, typeof(v)}(z, v)
end

function Base.getproperty(x::MyStruct{T, ZT, VT}, s::Symbol) where {T, ZT, VT}
    if hasfield(VT, s)
        return getfield(x, :v)[s]
    else
        return getfield(x, s)
    end
end

function test()
    m = MyStruct(rand(3), ( a = 1:2, b = 3 ))
    @time m.z
    @time m.a
    @time m.b
end

test();

If I have an inner constructor, creating the views statically leads to a type-stable getproperty, but creating them dynamically leads to an unstable getproperty. If I have an outer constructor, both lead to a type-stable getproperty.

I do not quite understand this behaviour. The type parameter VT of MyStruct has the same value in all examples.

I tested this with Julia v1.7.2 and 1.8.0-beta3 on macOS (M1).

I don’t really know why one constructor is type-stable and the other is not. However, note that you can alternatively use map to construct the NamedTuple v:

v = map(idx_range -> view(z, idx_range), vars)

which happens to make your inner constructor type-stable (in my tests at least). This returns a NamedTuple with the same names as vars, and with the values transformed by the mapping.

1 Like

Thanks a lot! This indeed does the trick.

I came across yet another variation:

struct MyStruct{T, ZT <: AbstractVector{T}, VT <: NamedTuple}
    z::ZT
    v::VT
end

# this is type-stable
function MyStruct(z::ZT, vars) where {ZT}
    v = NamedTuple{keys(vars)}(( view(z, idx_range) for idx_range in vars ))
    MyStruct{eltype(z), ZT, typeof(v)}(z, v)
end

# this is not type-stable
function MyStruct(z::ZT; vars = NamedTuple) where {ZT}
    v = NamedTuple{keys(vars)}(( view(z, idx_range) for idx_range in vars ))
    MyStruct{eltype(z), ZT, typeof(v)}(z, v)
end

function Base.getproperty(x::MyStruct{T, ZT, VT}, s::Symbol) where {T, ZT, VT}
    if hasfield(VT, s)
        return getfield(x, :v)[s]
    else
        return getfield(x, s)
    end
end

function test()
    # no allocations
    m = MyStruct(rand(3), ( a = 1:2, b = 3 ))
    @time m.z
    @time m.a
    @time m.b

    # allocations
    m = MyStruct(rand(3); vars = ( a = 1:2, b = 3 ))
    @time m.z
    @time m.a
    @time m.b
end

Note that the two constructors are identical, except for how its parameters are passed (argument vs. keyword argument).
It seems really strange that depending on how the object is constructed, its getproperty method is either type stable or not.

Another recent thread had a problem related to type-stability and keyword arguments. I am currently thinking that there may be some problem with Julia current handling of these two characteristics in tandem.

1 Like

Very good point. Indeed, if I put a function barrier in my test function, there are no allocations in either case:

function _test(m)
    @time m.z
    @time m.a
    @time m.b
end

function test()
    m = MyStruct(rand(3), ( a = 1:2, b = 3 ))
    _test(m)

    m = MyStruct(rand(3); vars = ( a = 1:2, b = 3 ))
    _test(m)
end
1 Like