Type stable specializations of `getproperty` without code duplication?

I have a parametric type that I’m trying to specialize getproperty for. There are some properties which I’d like to always access independently of the type parameter, and there are some that only apply to specific type parameters. I’d like to factor out the type parameter-independent implementation of getproperty to avoid code duplication, but I can’t seem to do so without losing type stability.

Here is a MWE:

struct Foo{B, T, ABC}
    bar::B
    xy::T
    abc::ABC
end

# this is used as a parameter in `Foo`
struct Bar{U}
    u::U
end

# shared `getproperty` code independent of the `bar` parameter
function _getproperty(x::Foo, s::Symbol)
    if s==:x
        return getfield(x, :xy)[1]
    elseif s==:a
        return getfield(x, :abc)[1]
    elseif s==:b
        return getfield(x, :abc)[2]
    else 
        return getfield(x, s)
    end
end

# `bar`-specific (and type unstable) `getproperty` code
function Base.getproperty(x::Foo{<:Bar}, s::Symbol)
    if s==:u
        return length(getfield(x.bar, :u))
    else
        _getproperty(x, s)
    end
end

using Test
function f(x::Foo)
    x.x, x.xy, x.a, x.u
end
bar = Bar(1)
@inferred f(Foo(bar, (1., 2), "ab"))  

The @inferred test fails with ERROR: return type Tuple{Float64, Tuple{Float64, Int64}, Char, Int64} does not match inferred return type NTuple{4, Any}.

The following code is inferrable but doesn’t factor the bar-independent code out and is harder to extend.

# this is type stable but harder to extend
function Base.getproperty(x::Foo{<:Bar}, s::Symbol)
    if s==:u
        return length(getfield(x.bar, :u))
    elseif s==:x
        return getfield(x, :xy)[1]
    elseif s==:a
        return getfield(x, :abc)[1]
    elseif s==:b
        return getfield(x, :abc)[2]
    else 
        return getfield(x, s)
    end
end

Is there a good way to factor out the “shared” getproperty code without losing type stability here?

Inlining _getproperty seems to help:

julia> struct Foo{B, T, ABC}
           bar::B
           xy::T
           abc::ABC
       end

julia> # this is used as a parameter in `Foo`
       struct Bar{U}
           u::U
       end

julia> # shared `getproperty` code independent of the `bar` parameter
       @inline function _getproperty(x::Foo, s::Symbol)
           if s === :x
               return getfield(x, :xy)[1]
           elseif s === :a
               return getfield(x, :abc)[1]
           elseif s === :b
               return getfield(x, :abc)[2]
           else 
               return getfield(x, s)
           end
       end
_getproperty (generic function with 1 method)

julia> # `bar`-specific (and type unstable) `getproperty` code
       function Base.getproperty(x::Foo{<:Bar}, s::Symbol)
           if s === :u
               return length(getfield(x.bar, :u))
           else
               _getproperty(x, s)
           end
       end

julia> using Test

julia> function f(x::Foo)
           x.x, x.xy, x.a, x.u
       end
f (generic function with 1 method)

julia> bar = Bar(1)
Bar{Int64}(1)

julia> @inferred f(Foo(bar, (1., 2), "ab"))
(1.0, (1.0, 2), 'a', 1)

This is using Julia v1.8.3.

2 Likes

Alternatively, setting Base.@constprop aggressive function _getproperty(x::Foo, s::Symbol).

2 Likes

Thanks @ranocha and @kristoffer.carlsson! Both approaches fix the MWE, but not the full example. The full example has a lot more if statements/fields, so I’m guessing inlining and constant propagation don’t behave the same for it.

I’m not too familiar with metaprogramming yet - would it potentially fix this?

Did you also use === instead of == to compare the symbols?

I guess you could write the common code in a macro and insert it into the getproperty overloads of each subtype you are using.

I tried === just now, but the type instability remains.

I’ll try out the macro. Thanks again for the help!

Using the macro seems to help, but there’s still one type instability related to the getproperty fallback getfield(x, s) that I can’t figure out. Here’s a MWE

module Baz

export Bar, Foo

struct Bar{U}
    u::U
end

struct Foo{B, T, ABC}
    bar::B
    xy::T
    abc::ABC
end

macro _getproperty(x, s)
    return :(
        if $(esc(s))==:x
            return getfield($(esc(x)), :xy)[1]
        elseif $(esc(s))==:a
            return getfield($(esc(x)), :abc)[1]
        elseif $(esc(s))==:b
            return getfield($(esc(x)), :abc)[2]
        else 
            return getfield($(esc(x)), $(esc(s)))
        end
    )
end

# `bar`-specific (and type unstable) `getproperty` code
function Base.getproperty(x::Foo{<:Bar}, s::Symbol)
    if s==:u
        return length(getfield(x.bar, :u))
    else
        @_getproperty(x, s)
    end
end

end # module

The following inference test then fails

using .Baz
bar = Bar(1)
x = Foo(bar, (1., 2), "ab")

using Test
function f(x::Foo)
    x.x, x.xy, x.a, x.u
end
@inferred f(x)

Examining the output from @code_warntype x.u shows the type instability in getfield

(screenshot used instead of copy/pasted output to highlight type instability coloring)

I’m not sure why this is type unstable - is this an escaping issue?

this might be an illusion,

julia> struct Foo
           a::Int
           b::Float64
       end

julia> const f = Foo(1, 2.0);

as long as your getproperty comes from source code inside a function, the constant prop kicks in, which is how you should be doing this btw

2 Likes

Doesn’t seem to be an illusion:

g() = x.u
@code_warntype g()

results in

Same if I define g(x) = x.u.

Avoiding potential self-recursion should help (if allowed in your real use case):

julia> module Baz

       export Bar, Foo

       struct Bar{U}
           u::U
       end

       struct Foo{B, T, ABC}
           bar::B
           xy::T
           abc::ABC
       end

       macro _getproperty(x, s)
           return :(
               if $(esc(s)) === :x
                   return getfield($(esc(x)), :xy)[1]
               elseif $(esc(s)) === :a
                   return getfield($(esc(x)), :abc)[1]
               elseif $(esc(s)) === :b
                   return getfield($(esc(x)), :abc)[2]
               else 
                   return getfield($(esc(x)), $(esc(s)))
               end
           )
       end

       # `bar`-specific (and type unstable) `getproperty` code
       function Base.getproperty(x::Foo{<:Bar}, s::Symbol)
           if s === :u
               return length(getfield(getfield(x, :bar), :u))
           else
               @_getproperty(x, s)
           end
       end

       end # module
Main.Baz

julia> using .Baz

julia> bar = Bar(1)
Bar{Int64}(1)

julia> x = Foo(bar, (1., 2), "ab")
Foo{Bar{Int64}, Tuple{Float64, Int64}, String}(Bar{Int64}(1), (1.0, 2), "ab")

julia> using Test

julia> function f(x::Foo)
           x.x, x.xy, x.a, x.u
       end
f (generic function with 1 method)

julia> @inferred f(x)
(1.0, (1.0, 2), 'a', 1)

julia> g(x) = x.u
g (generic function with 1 method)

julia> @code_warntype g(x)
MethodInstance for g(::Foo{Bar{Int64}, Tuple{Float64, Int64}, String})
  from g(x) in Main at REPL[8]:1
Arguments
  #self#::Core.Const(g)
  x::Foo{Bar{Int64}, Tuple{Float64, Int64}, String}
Body::Int64
1 ─ %1 = Base.getproperty(x, :u)::Core.Const(1)
└──      return %1

Note that I used return length(getfield(getfield(x, :bar), :u)) instead of return length(getfield(x.bar, :u)).

3 Likes

Is x a const in this example? If not, you will have an issue due to accessing a non-constant global x.

1 Like

I think that was it! Thanks again @ranocha.

@baggepinnen x wasn’t const, but I tried using g(x) = x.u to avoid issues with globals and saw the same issue. I think it was the problem that I was accessing x.bar within getproperty as @ranocha pointed out.

I think this may also be a version of Mysterious inference failure that can be solved by redefining function · Issue #35537 · JuliaLang/julia · GitHub - redefining _getproperty in the REPL also solved the type instability for me.

is this x global non-constant?

Does the non-recursive version alsow work fine with _getproperty as a function?

Unfortunately, no - I tried that in the full example after you found the recursive error, but it still doesn’t infer properly.

Edit: the function version does appear to infer if we use @inline or Base.@constprop aggressive!

It is, but I checked g(x) = x.u as well and the same type instability appeared. However, this appears to be due to the recursive definition of getproperty - inside getproperty, I called x.bar. As @ranocha pointed out, replacing this with getfield(x, :bar) fixes things.

Another suggestion is implementing it as

Base.getproperty(x::Foo, s::Symbol) = _getproperty(x, Val(s))
_getproperty(x::Foo, ::Val{:x}) = getfield(x, :xy)[1]
_getproperty(x::Foo, ::Val{:a}) = getfield(x, :abc)[1]
_getproperty(x::Foo, ::Val{:b}) = getfield(x, :abc)[2]
_getproperty(x::Foo, ::Val{S}) where {S} = getfield(x, S)

Constprop should have an easy time working on figuring out the type of the Val, and then all remaining functions should have types inferable based solely on input types, making them easier to investigate and debug.

module Baz1

export Bar, Foo

struct Bar{U}
    u::U
end

struct Foo{B, T, ABC}
    bar::B
    xy::T
    abc::ABC
end
const FooBar = Foo{<:Bar}

Base.getproperty(x::FooBar, s::Symbol) = _getproperty(x, Val(s))
_getproperty(x::FooBar, ::Val{:x}) = getfield(x, :xy)[1]
_getproperty(x::FooBar, ::Val{:a}) = getfield(x, :abc)[1]
_getproperty(x::FooBar, ::Val{:b}) = getfield(x, :abc)[2]
_getproperty(x::FooBar, ::Val{S}) where {S} = getfield(x, S)
_getproperty(x::FooBar, ::Val{:u}) = length(getfield(getfield(x, :bar), :u))

end # module

bar = Baz1.Bar(1)
x = Baz1.Foo(bar, (1., 2), "ab")

using Test
function f(x::Baz1.Foo)
    x.x, x.xy, x.a, x.u
end
@inferred f(x)

Anyway, this still needed getfield(x, :bar), like @ranocha’s suggestion.
You should always avoid recursions like this, especially when they’re so unnecessary.

FWIW, I think this approach is the most readable.

4 Likes

Repeating the same _getproperty as many times as there are fields, having a bunch of Val etc? Does not feel the most readable to me over simple if statements.

Sure, largely a matter of taste.
My preference probably comes in part because it’s shorter, and in part because I have limited trust for constant propagation so the less hermetic the lines relying on it, the less comfortable I am.
It works 99% of the time, which is enough to use it and end up spending a ton of time taking down the cases where it didn’t work.

I know const propped specializations do get stored somewhere, so I’m not sure which is “lighter”, but would guess the if statements are.