Unexpected type instability with getproperty but not setproperty!

Julia 1.1.0 on macOS mojave

In the following example, I’d expect branch elimination to make both of the following type stable, however getproperty isn’t type stable and is allocating. Could someone shed some light on why this is happening?


mutable struct Entry
    id::Int
    data::Vector{Float64}
end

function Base.getproperty(e::Entry, s::Symbol)
    if s === :value
        e.data[e.id]
    else
        getfield(e, s)
    end
end

function Base.setproperty!(e::Entry, s::Symbol, c)
    if s === :value
        e.data[e.id] = c
    else
        setfield!(e, s, c)
    end
end

function example_read(e)
    e.value
end

function example_write(e)
    e.value = 0.0
end

function main()
    storage = randn(1000000)
    entry = Entry(123, storage)
    @code_warntype example_read(entry)
    @code_warntype example_write(entry)
end

main()

Which results in

Body::Union{Float64, Int64, Array{Float64,1}}
1 ─ %1 = invoke Base.getproperty(_2::Entry, :value::Symbol)::Union{Float64, Int64, Array{Float64,1}}
└──      return %1
Body::Float64
1 ─      goto #3 if not false
2 ─      nothing
3 ┄ %3 = (Main.getfield)(e, :data)::Array{Float64,1}
│   %4 = (Main.getfield)(e, :id)::Int64
│        (Base.arrayset)(true, %3, 0.0, %4)
└──      return 0.0

(you’ll have to trust me that the improvement in the user interface for my use case is worth the effort here. This is just a minimal example)

How can I make the above type stable?

The problem seems to be the return type of getproperty, which either:

  • returns a Float64 if s === :value or else
  • some other field. The options in your case are an Int (that would be id) or a Vector{Float64} (that would be data).

If you can get any of these, the matching type would be Union{Float64, Int64, Vector{Float64}} which is exactly what you got.

So if you want to be able to do what you are doing I don’t think you can avoid the type instability (think: what would you want the return type to be instead of the Union above?).

I would have expected the other branches to be eliminated since :value is a compile time constant, re Stefan’s comment here How to use `getproperty`/`setproperty!`?.

For example:

const branch = true
example() = branch ? 0.0 : 0
@code_warntype example()

example() “looks” type unstable, but the compiler correctly eliminates the branch and then infers the type, meaning no type instability.

Body::Float64
1 ─     return 0.0

I’m asking why the same isn’t happening here, since (I think) :value is a compile time constant.

Hmm but the same isn’t true if it takes an argument.

const branch = :value
example(foo) = foo === :value ? 0.0 : 0
@code_warntype example(branch)

yields

Body::Union{Float64, Int64}
1 ─ %1 = (foo === :value)::Bool
└──      goto #3 if not %1
2 ─      return 0.0
3 ─      return 0

So I guess there’s only so much inference the compiler can do in that case. I’ll see if dispatching on value types doesn’t introduce much overhead.

As you’ve noted, s is not a compile time constant so you can’t at compile time tell if it is equal to :value or not. Have you tried a function that actually uses this syntax? The s is not a compile time constant in the getproperty definition (how could it define different behaviors then?), it’s constant in the context that calls getproperty. That constant will be propagated through the getproperty definition as long as the definition is not too complicated.

2 Likes

So the context here is that I’m trying to provide a mutable struct like API for users writing some sort of kernel that will then be executed against data in some backing store (here represented by the Vector{Float64}. So the idea is that the user can write code against the entry itself like:

function user_code(e::Entry)
    current = e.value
    e.value = current ^ 2
end

In reality there would be several entry types each with a particular set of fields. Then behind the scenes I can execute their code like so.

function main()
    storage = randn(1000000)
    entry = Entry(0, storage)

    @time for i=1:1000000
        entry.id = i
        user_code(entry)
    end
end
#   0.055401 seconds (999.49 k allocations: 15.251 MiB)

So in the context of user_code in main, the value for s passed into getpropery is a compile time constant right? Since :value is written there in the code. That’s my confusion I guess. It’s constant in the calling context, but it doesn’t seem to get propagated.

Can you show the code warn output for that?

New main (using previous definition of user_code)

function main()
    storage = randn(1000000)
    entry = Entry(1, storage)

    @code_warntype user_code(entry)
end

main()

Output

Body::Union{Float64, Int64}
1 ── %1  = invoke Base.getproperty(_2::Entry, :value::Symbol)::Union{Float64, Int64, Array{Float64,1}}
│    %2  = Base.literal_pow::Core.Compiler.Const(Base.literal_pow, false)
│    %3  = Main.:^::Core.Compiler.Const(^, false)
│    %4  = (isa)(%1, Float64)::Bool
└───       goto #3 if not %4
2 ── %6  = π (%1, Float64)
│    %7  = invoke %2(%3::typeof(^), %6::Float64, $(QuoteNode(Val{2}()))::Val{2})::Union{Float64, Int64}
└───       goto #8
3 ── %9  = (isa)(%1, Int64)::Bool
└───       goto #5 if not %9
4 ── %11 = π (%1, Int64)
│    %12 = (Base.mul_int)(%11, %11)::Int64
└───       goto #8
5 ── %14 = (isa)(%1, Array{Float64,1})::Bool
└───       goto #7 if not %14
6 ── %16 = π (%1, Array{Float64,1})
│          (%3)(%16, 2)
│          $(Expr(:unreachable))
└───       $(Expr(:unreachable))
7 ┄─       (Core.throw)(ErrorException("fatal error in type inference (type bound)"))
└───       $(Expr(:unreachable))
8 ┄─ %22 = φ (#2 => %7, #4 => %12)::Union{Float64, Int64}
└───       goto #10 if not false
9 ──       nothing
10 ┄ %25 = (isa)(%22, Float64)::Bool
└───       goto #15 if not %25
11 ─ %27 = π (%22, Float64)
│    %28 = (:value === :value)::Bool
└───       goto #13 if not %28
12 ─ %30 = (Main.getfield)(e, :data)::Array{Float64,1}
│    %31 = (Main.getfield)(e, :id)::Int64
│          (Base.arrayset)(true, %30, %27, %31)
└───       goto #14
13 ─       (Main.setfield!)(e, :value, %27)
└───       goto #14
14 ┄       goto #21
15 ─ %37 = (isa)(%22, Int64)::Bool
└───       goto #20 if not %37
16 ─ %39 = π (%22, Int64)
│    %40 = (:value === :value)::Bool
└───       goto #18 if not %40
17 ─ %42 = (Main.getfield)(e, :data)::Array{Float64,1}
│    %43 = (Main.getfield)(e, :id)::Int64
│    %44 = (Base.sitofp)(Float64, %39)::Float64
│          (Base.arrayset)(true, %42, %44, %43)
└───       goto #19
18 ─       (Main.setfield!)(e, :value, %39)
└───       goto #19
19 ┄       goto #21
20 ─       (Core.throw)(ErrorException("fatal error in type inference (type bound)"))
└───       $(Expr(:unreachable))
21 ┄       return %22

So yep seems like something is going awry, though my understanding of the Julia IR is pretty much non existent.

Similar situation on 1.3.0-alpha:

Variables
  #self#::Core.Compiler.Const(user_code, false)
  e::Entry
  current::Union{Float64, Int64, Array{Float64,1}}

Body::Union{Float64, Int64}
1 ─      (current = Base.getproperty(e, :value))
│   %2 = current::Union{Float64, Int64, Array{Float64,1}}
│   %3 = Core.apply_type(Base.Val, 2)::Core.Compiler.Const(Val{2}, false)
│   %4 =
(%3)()::Core.Compiler.Const(Val{2}(), false)
│   %5 = Base.literal_pow(Main.:^, %2, %4)::Union{Float64, Int64}
│        Base.setproperty!(e, :value, %5)
└──      return %5

Constant propagation doesn’t really work with recursion. Use:

function Base.getproperty(e::Entry, s::Symbol)
    if s === :value
        getfield(e, :data)[getfield(e, :id)]
    else
        getfield(e, s)
    end
end
5 Likes

Wow you absolute hero, this totally fixes everything, and it makes perfect sense why what I was trying to do wouldn’t work.

Thanks everyone for your help. This is going to let me provide a super slick interface that doesn’t compromise on performance.