How to use getproperty efficiently?

metaprogramming

#1

Inspired by Zero-Cost Abstractions I tried to implement field based access for a dictionary. It kinda works, but could it be improved? Why isn’t the last function working as fast as it should? I’m using julia v1.0.3.

using BenchmarkTools

struct A{T}
    __dict::Dict{Symbol, T}
end
a = A{Float64}(Dict(:x => 0.3))

f(a) = a.__dict[:x]
@btime f(a) #  19.032 ns (1 allocation: 16 bytes)

@inline @generated getter(a::A,::Val{s}) where s = :(getfield(a, :__dict)[s])
@inline Base.getproperty(a::A, s::Symbol) = getter(a, Val(s))
g(a) = a.x
@btime g(a) #  18.775 ns (1 allocation: 16 bytes)

@inline @generated getter(a::A,::Val{:__dict}) = :(getfield(a, :__dict))
h(a) = a.__dict
@btime h(a) #  10.469 ns (0 allocations: 0 bytes)

@inline @generated getter(a::A,::Val{s}) where s = :(a.__dict[s])
i(a) = a.x
@btime i(a) #   2.370 μs (1 allocation: 16 bytes)

#2
@btime f($a) # 13.770 ns (0 allocations: 0 bytes)
@btime f(a) setup = a = A{Float64}(Dict(:x => 0.3)) # 14.301 ns (0 allocations: 0 bytes)

h just returns the __dict field by the way.


#3

Thanks for the tip! Still strange that replacing a.__dict with h(a) makes everything fast again:

@inline @generated getter(a::A,::Val{s}) where s = :(h(a)[s])
j(a) = a.x
@btime j($a) #   7.297 ns (0 allocations: 0 bytes)

#4

j and f benchmark the same for me; in fact their code_llvm is the same. Looks like the type-unstable Val creation is being constant-folded.

Lifting values into the type system can only serve to slow things down in this case (or give the same performance if the compiler optimizes it away, as with j). No amount of type system trickery can make the Dict lookup faster.

BTW, you need to use Base.@_inline_meta as the first statement in the expression returned by @generated functions instead of @inline @generated. E.g. as in


#5

If the properties are fixed upon construction you could use ImmutableDict though:

struct B{T}
    __dict::Base.ImmutableDict{Symbol, T}
end
Base.getproperty(b::B, s::Symbol) = Core.getproperty(b, :__dict)[s]
julia> using BenchmarkTools

julia> f(b) = b.x

julia> b = B{Float64}(Base.ImmutableDict(:x => 0.3));

julia> @btime f($b)
  0.046 ns (0 allocations: 0 bytes)
0.3

but:

julia> @btime f(b) setup = b = B{Float64}(Base.ImmutableDict(:x => 0.3))
  3.930 ns (0 allocations: 0 bytes)
0.3

In the former case, the optimizer constant-folds everything away.

Edit: but I guess at that point, just use proper fields.


#6

Why do you need @generated at all here? You’re still doing the dictionary lookup in the generated code. The thing about the blog post you linked is that it does the lookup outside the generated expression, at compile time (hence “zero-cost”). Is that what you wanted to do here as well, or did you just want to implement field based dictionary access?


#7

I mainly wanted to achieve what @tkoolen has figured it out with ImmutableDict - so there and no dictionary lookups at all, you are right, @generated is not needed there.

But I also wanted to have other fields in A still accessible and have correct type information. Not sure if it is possible without @generated and Val trick.


#8

And I was missing the Core vs. Base distinction in that line, so without @generated it was giving me stack overflow :slight_smile:


#9

A simple if tree will do in this case. If the property name is a constant, the optimizer will optimize away everything but the relevant branch, making the function type stable.

struct A{T}
    __dict::Dict{Symbol, T}
end
function Base.getproperty(a::A, s::Symbol)
    if s === :__dict
        return Core.getproperty(a, :__dict)
    else
        return Core.getproperty(a, :__dict)[s]
    end
end
f(a) = a.x
g(a) = a.__dict
julia> a = A{Float64}(Dict(:x => 0.3));

julia> @code_warntype f(a)
Body::Float64
1 1 ─ %1  = Core.getproperty::typeof(getfield)                                                                                                                                                                           │╻   getproperty
  │   %2  = (%1)(a, :__dict)::Dict{Symbol,Float64}                                                                                                                                                                       ││
  │   %3  = invoke Base.ht_keyindex(%2::Dict{Symbol,Float64}, :x::Symbol)::Int64                                                                                                                                         ││╻   getindex
  │   %4  = (Base.slt_int)(%3, 0)::Bool                                                                                                                                                                                  │││╻   <
  └──       goto #3 if not %4                                                                                                                                                                                            │││
  2 ─ %6  = %new(Base.KeyError, :x)::KeyError                                                                                                                                                                            │││╻   Type
  │         (Base.throw)(%6)                                                                                                                                                                                             │││
  └──       $(Expr(:unreachable))                                                                                                                                                                                        │││
  3 ─ %9  = (Base.getfield)(%2, :vals)::Array{Float64,1}                                                                                                                                                                 │││╻   getproperty
  │   %10 = (Base.arrayref)(false, %9, %3)::Float64                                                                                                                                                                      │││╻   getindex
  └──       goto #5                                                                                                                                                                                                      │││
  4 ─       $(Expr(:unreachable))                                                                                                                                                                                        │││
  5 ┄       goto #6                                                                                                                                                                                                      ││
  6 ─       return %10                                                                                                                                                                                                   │

julia> @code_warntype g(a)
Body::Dict{Symbol,Float64}
1 1 ─ %1 = Core.getproperty::typeof(getfield)                                                                                                                                                                                │╻ getproperty
  │   %2 = (%1)(a, :__dict)::Dict{Symbol,Float64}                                                                                                                                                                            ││
  └──      return %2

#10

Recently I implemented something similar, I would suggest this kind of approach as @tkoolen:

This way you can access the fields and the dictionary. In my case, the dictionary is used to look up an index for fetching an element of an SVector.


#11

This will instantiate the vector [:s, :b, :g] in every call and cannot be constant folded, better to use a tuple:

julia> struct Algebra
         s::Float64
         b::Int
         g::Int
       end

julia> const D = Dict(:q => 2, :t => 3)
Dict{Symbol,Int64} with 2 entries:
  :q => 2
  :t => 3

julia> Base.getproperty(a::Algebra,v::Symbol) = v ∈ [:s,:b,:g] ? getfield(a,v) : D[v]

julia> @code_warntype f(A)
Body::Union{Float64, Int64}
1 ─ %1 = invoke Base.getproperty(_2::Algebra, :s::Symbol)::Union{Float64, Int64}
└──      return %1

julia> Base.getproperty(a::Algebra,v::Symbol) = v ∈ (:s,:b,:g) ? getfield(a,v) : a[a.g[v]]

julia> @code_warntype f(A)
Body::Float64
1 ─ %1 = (Main.getfield)(A, :s)::Float64
└──      return %1