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