Getproperty/getfield type instability?

I’m trying to understand type stability when accessing composite types.

Consider the custom datatype:

struct MyStruct
    a::Int
    b::Number
    c::AbstractDict
end

I create an instance,

obj = MyStruct(1, 2.0, Dict())

Running @code_warntype on obj.a shows that getproperty is not type stable:

julia> @code_warntype obj.a
MethodInstance for getproperty(::MyStruct, ::Symbol)
  from getproperty(x, f::Symbol) in Base at Base.jl:38
Arguments
  #self#::Core.Const(getproperty)
  x::MyStruct
  f::Symbol
Body::Any
1 ─      nothing
│   %2 = Base.getfield(x, f)::Any
└──      return %2

of course, ::Any is highlighted in red in the REPL.

Next, if I try @code_warntype on getfield, I get this:

julia> @code_warntype getfield(obj, :a)
getfield(...) in Core
  failed to infer

When passing such objects as arguments to functions which access their properties, obviously the type instability is resolved somehow when the function attempts to access obj.a. For example,

julia> function g(x) x.a+1 end

julia> @code_warntype g(obj)
MethodInstance for g(::MyStruct)
  from g(x) in Main at REPL[9]:1
Arguments
  #self#::Core.Const(g)
  x::MyStruct
Body::Int64
1 ─ %1 = Base.getproperty(x, :a)::Int64
│   %2 = (%1 + 1)::Int64
└──      return %2

What’s happening here? I’ve tried to look for applicable definitions of getproperty and getfield to see how the type instability is resolved, but so far I couldn’t seem to find them.

Thanks~

Observe:

julia> struct MyStruct
           a::Int
       end

julia> obj = MyStruct(1)
MyStruct(1)

julia> @code_warntype getproperty(obj, :a)
MethodInstance for getproperty(::MyStruct, ::Symbol)
  from getproperty(x, f::Symbol) in Base at Base.jl:38
Arguments
  #self#::Core.Const(getproperty)
  x::MyStruct
  f::Symbol
Body::Int64
1 ─      nothing
│   %2 = Base.getfield(x, f)::Int64
└──      return %2

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

julia> obj2 = MyStruct2(1, 2)
MyStruct2(1, 2.0)

julia> @code_warntype getproperty(obj2, :a)
MethodInstance for getproperty(::MyStruct2, ::Symbol)
  from getproperty(x, f::Symbol) in Base at Base.jl:38
Arguments
  #self#::Core.Const(getproperty)
  x::MyStruct2
  f::Symbol
Body::Union{Float64, Int64}
1 ─      nothing
│   %2 = Base.getfield(x, f)::Union{Float64, Int64}
└──      return %2

basically, getproperty can only see two types, one is the MyStruct, the other is merely a Symbol, nominally, there’s no way for getproperty to know which field you will access purely based on the Symbol being Symbol – it could be any field.

However, I think in this case the output of code_warntype may be misleading, it should have constant propagation (just like what you see when you put this into a function), but for some reason the result is not showing the effect of constant propagation

It’s not showing the result of constant propagation because that’s not how @code_warntype works - it can’t propagate constants from the top level. This:

is equivalent to

@code_warntype getproperty(obj, :a)

which is the same as

InteractiveUtils.code_warntype(getproperty, (Base.typesof)(obj, :a))

which ultimately is

InteractiveUtils.code_warntype(getproperty, Tuple{MyStruct, Symbol})

So there’s no Symbol object to propagate because code_warntype only cares about the input types, not their values. Not even obj survives. For inspecting what syntax transforms macros perform, @macroexpand is very useful.

If you use obj.a in a function, the :a is of course again a constant inside a call, which is propagated. Just as can be observed in the g(x) example.

Both getproperty and getfield rely on constant propagation of the input symbol to be type stable. Using them in a function, where the accessed fields are hardcoded, allows them to be propagated as constants and thus resolve the field access in a type stable manner.

Further, only getproperty is configurable. By default it just forwards to getfield, which is the builtin version that’s typically used when overloading getproperty for a custom type.

As to why @code_warntype works this way - it’s intended for figuring out whether or not the function that’s called is type stable, not whether the Expr that it’s used on is.

4 Likes

To add to the previous comments: It is also good practice to make all fields of your struct concrete instead of abstract types. So instead of

struct MyStruct
    a::Int
    b::Number
    c::AbstractDict
end

you might want to use

struct MyStruct{NT<:Number,DT<:AbstractDict}
    a::Int
    b::NT
    c::DT
end

This ensures that when the compiler encounters a function f(x::MyStruct) that accesses the fields of x it compile a nice and fast method, depending on what exactly NT and DT are.

1 Like