Slowness of fieldnames and propertynames

So I’ve seen disucssions of this pop up a few times, but I figure it might be good to have a more recent thread here on discourse that we can point people to (and maybe it can motivate a PR / issue to julia)

The problem:

julia> struct Foo
           f1::Int
           f2::Int
           f3::Int
       end

julia> @btime fieldnames($Foo)
  396.512 ns (1 allocation: 32 bytes)
(:f1, :f2, :f3)

julia> @btime propertynames($(Foo(1, 2, 3)))
  394.821 ns (1 allocation: 32 bytes)
(:f1, :f2, :f3)

There are various usecases where one might want to reflect on these things without paying such a hefty performance penalty.

To my naive eye, it seems like at the very least the fieldnames is information that can be statically known and reflected on. It’s not like we are allowed to redefine the fieldnames:

julia> struct Foo
           f1::Int
           f2::Int
           some_other_fieldname::Int
       end
ERROR: invalid redefinition of constant Foo

In this thread: Static fieldnames, the OP was told to just use a generated function to manually hoist the reflection to compiletime, but that leaves me a little puzzled.

If it’s okay to reflect on this sort of thing inside a generated function body, why is it not okay for the compiler to just make this a tfunc or whatever and give us these results as compiletime constants?


Edit: I somehow missed https://github.com/JuliaLang/julia/issues/29100 when I was searching around, so there is a somewhat up to date discussion of this.

6 Likes

I think this might be a related issue: I wanted to convert a struct into a NamedTuple (with the keys being the field names of the struct), and while it seems like the compiler has all the info it needs with

named_tuple(obj::T) where {T} = NamedTuple{fieldnames(T), Tuple{fieldtypes(T)...}}(ntuple(i -> getfield(obj, i), fieldcount(T)))

it is much slower than the equivalent generated function

@generated function named_tuple_gen(obj)
    NT = NamedTuple{fieldnames(obj), Tuple{fieldtypes(obj)...}}
    return :($NT(tuple($((:(getfield(obj, $i)) for i in 1:fieldcount(obj))...))))
end

which I got working by modifying Static fieldnames - #2 by yuyichao; thanks for the link! (I actually had to expand it out as

@generated function  named_tuple_gen(obj::T) where {T}
    NT = NamedTuple{fieldnames(obj), Tuple{fieldtypes(obj)...}}
    return :(
                $NT(
                    tuple(
                        $(
                            (
                                :(getfield(obj, $i)) for i in 1:fieldcount(obj)
                            )...
                        )
                    )
                )
            )
end

to see what’s going on better; VSCode makes little vertical lines which makes it a bit easier to parse the paren alignment than here, though).

It seemed like I could recover some of the performance without generated functions if I wanted it just for a specific type T by doing

const NT = NamedTuple{fieldnames(T), Tuple{fieldtypes(T)...}}
T_as_NT(obj::T) = NT(ntuple(i -> getfield(obj, i), fieldcount(T)))

which at least lets it infer the output type correctly, though if there are more than 10 fields it can’t infer the output of ntuple. (It seems basically as fast as the generated function for fewer than 10 fields; the cutoff of course is from julia/ntuple.jl at a319ae45a15fb005c7d7e29e3f3a62f27e18e3a6 Β· JuliaLang/julia Β· GitHub).

Example with 11 fields
julia> using BenchmarkTools

julia> named_tuple(obj::T) where {T} = NamedTuple{fieldnames(T), Tuple{fieldtypes(T)...}}(ntuple(i -> getfield(obj, i), fieldcount(T)))
named_tuple (generic function with 1 method)

julia> @generated function  named_tuple_gen(obj::T) where {T}
           NT = NamedTuple{fieldnames(obj), Tuple{fieldtypes(obj)...}}
           return :($NT(tuple($((:(getfield(obj, $i)) for i in 1:fieldcount(obj))...))))
       end
named_tuple_gen (generic function with 1 method)

julia> struct ManyBools
           x1::Bool
           x2::Bool
           x3::Bool
           x4::Bool
           x5::Bool
           x6::Bool
           x7::Bool
           x8::Bool
           x9::Bool
           x10::Bool
           x11::Bool
       end

julia> const NT = NamedTuple{fieldnames(ManyBools), Tuple{fieldtypes(ManyBools)...}}
NamedTuple{(:x1, :x2, :x3, :x4, :x5, :x6, :x7, :x8, :x9, :x10, :x11), NTuple{11, Bool}}

julia> ManyBools_as_NT(obj::ManyBools) = NT(ntuple(i -> getfield(obj, i), fieldcount(ManyBools)))
ManyBools_as_NT (generic function with 1 method)

julia> obj = ManyBools(rand(Bool, 11)...)
ManyBools(false, false, false, false, false, true, false, true, false, false, true)

julia> @benchmark named_tuple($(Ref(obj))[])

BenchmarkTools.Trial: 
  memory estimate:  528 bytes
  allocs estimate:  6
  --------------
  minimum time:     2.768 ΞΌs (0.00% GC)
  median time:      2.806 ΞΌs (0.00% GC)
  mean time:        2.856 ΞΌs (0.75% GC)
  maximum time:     218.773 ΞΌs (98.07% GC)
  --------------
  samples:          10000
  evals/sample:     9

julia> @benchmark named_tuple_gen($(Ref(obj))[])
BenchmarkTools.Trial: 
  memory estimate:  0 bytes
  allocs estimate:  0
  --------------
  minimum time:     2.125 ns (0.00% GC)
  median time:      2.250 ns (0.00% GC)
  mean time:        2.257 ns (0.00% GC)
  maximum time:     23.250 ns (0.00% GC)
  --------------
  samples:          10000
  evals/sample:     1000

julia> @benchmark ManyBools_as_NT($(Ref(obj))[])
BenchmarkTools.Trial: 
  memory estimate:  128 bytes
  allocs estimate:  2
  --------------
  minimum time:     351.827 ns (0.00% GC)
  median time:      357.280 ns (0.00% GC)
  mean time:        368.931 ns (1.09% GC)
  maximum time:     9.198 ΞΌs (95.65% GC)
  --------------
  samples:          10000
  evals/sample:     214

julia> @code_warntype named_tuple(obj)
Variables
  #self#::Core.Const(named_tuple)
  obj::ManyBools
  #5::var"#5#6"{ManyBools}

Body::NamedTuple
1 ─ %1  = Main.fieldnames($(Expr(:static_parameter, 1)))::Tuple{Vararg{Symbol, N} where N}
β”‚   %2  = Core.tuple(Main.Tuple)::Core.Const((Tuple,))
β”‚   %3  = Main.fieldtypes($(Expr(:static_parameter, 1)))::Tuple
β”‚   %4  = Core._apply_iterate(Base.iterate, Core.apply_type, %2, %3)::Type
β”‚   %5  = Core.apply_type(Main.NamedTuple, %1, %4)::Type{NamedTuple{_A, _B}} where {_A, _B}
β”‚   %6  = Main.:(var"#5#6")::Core.Const(var"#5#6")
β”‚   %7  = Core.typeof(obj)::Core.Const(ManyBools)
β”‚   %8  = Core.apply_type(%6, %7)::Core.Const(var"#5#6"{ManyBools})
β”‚         (#5 = %new(%8, obj))
β”‚   %10 = #5::var"#5#6"{ManyBools}
β”‚   %11 = Main.fieldcount($(Expr(:static_parameter, 1)))::Core.Const(11)
β”‚   %12 = Main.ntuple(%10, %11)::Tuple{Vararg{Bool, N} where N}
β”‚   %13 = (%5)(%12)::NamedTuple
└──       return %13

julia> @code_warntype named_tuple_gen(obj)
Variables
  #self#::Core.Const(named_tuple_gen)
  obj::ManyBools

Body::NamedTuple{(:x1, :x2, :x3, :x4, :x5, :x6, :x7, :x8, :x9, :x10, :x11), NTuple{11, Bool}}
1 ─ %1  = Main.getfield(obj, 1)::Bool
β”‚   %2  = Main.getfield(obj, 2)::Bool
β”‚   %3  = Main.getfield(obj, 3)::Bool
β”‚   %4  = Main.getfield(obj, 4)::Bool
β”‚   %5  = Main.getfield(obj, 5)::Bool
β”‚   %6  = Main.getfield(obj, 6)::Bool
β”‚   %7  = Main.getfield(obj, 7)::Bool
β”‚   %8  = Main.getfield(obj, 8)::Bool
β”‚   %9  = Main.getfield(obj, 9)::Bool
β”‚   %10 = Main.getfield(obj, 10)::Bool
β”‚   %11 = Main.getfield(obj, 11)::Bool
β”‚   %12 = Main.tuple(%1, %2, %3, %4, %5, %6, %7, %8, %9, %10, %11)::NTuple{11, Bool}
β”‚   %13 = (NamedTuple{(:x1, :x2, :x3, :x4, :x5, :x6, :x7, :x8, :x9, :x10, :x11), NTuple{11, Bool}})(%12)::NamedTuple{(:x1, :x2, :x3, :x4, :x5, :x6, :x7, :x8, :x9, :x10, :x11), NTuple{11, Bool}}
└──       return %13

julia> @code_warntype ManyBools_as_NT(obj)
Variables
  #self#::Core.Const(ManyBools_as_NT)
  obj::ManyBools
  #10::var"#10#11"{ManyBools}

Body::NamedTuple{(:x1, :x2, :x3, :x4, :x5, :x6, :x7, :x8, :x9, :x10, :x11), NTuple{11, Bool}}
1 ─ %1 = Main.:(var"#10#11")::Core.Const(var"#10#11")
β”‚   %2 = Core.typeof(obj)::Core.Const(ManyBools)
β”‚   %3 = Core.apply_type(%1, %2)::Core.Const(var"#10#11"{ManyBools})
β”‚        (#10 = %new(%3, obj))
β”‚   %5 = #10::var"#10#11"{ManyBools}
β”‚   %6 = Main.fieldcount(Main.ManyBools)::Core.Const(11)
β”‚   %7 = Main.ntuple(%5, %6)::Tuple{Vararg{Bool, N} where N}
β”‚   %8 = Main.NT(%7)::NamedTuple{(:x1, :x2, :x3, :x4, :x5, :x6, :x7, :x8, :x9, :x10, :x11), NTuple{11, Bool}}
└──      return %8
Example with 9 fields
julia> using BenchmarkTools

julia> named_tuple(obj::T) where {T} = NamedTuple{fieldnames(T), Tuple{fieldtypes(T)...}}(ntuple(i -> getfield(obj, i), fieldcount(T)))
named_tuple (generic function with 1 method)

julia> @generated function  named_tuple_gen(obj::T) where {T}
           NT = NamedTuple{fieldnames(obj), Tuple{fieldtypes(obj)...}}
           return :($NT(tuple($((:(getfield(obj, $i)) for i in 1:fieldcount(obj))...))))
       end
named_tuple_gen (generic function with 1 method)

julia> struct FewerBools
           x1::Bool
           x2::Bool
           x3::Bool
           x4::Bool
           x5::Bool
           x6::Bool
           x7::Bool
           x8::Bool
           x9::Bool
       end

julia> const NT = NamedTuple{fieldnames(FewerBools), Tuple{fieldtypes(FewerBools)...}}
NamedTuple{(:x1, :x2, :x3, :x4, :x5, :x6, :x7, :x8, :x9), NTuple{9, Bool}}

julia> FewerBools_as_NT(obj::FewerBools) = NT(ntuple(i -> getfield(obj, i), fieldcount(FewerBools)))
FewerBools_as_NT (generic function with 1 method)

julia> obj = FewerBools(rand(Bool, 9)...)
FewerBools(false, true, false, true, true, true, false, true, false)

julia> @benchmark named_tuple($(Ref(obj))[])
BenchmarkTools.Trial: 
  memory estimate:  384 bytes
  allocs estimate:  5
  --------------
  minimum time:     2.083 ΞΌs (0.00% GC)
  median time:      2.120 ΞΌs (0.00% GC)
  mean time:        2.152 ΞΌs (0.00% GC)
  maximum time:     4.940 ΞΌs (0.00% GC)
  --------------
  samples:          10000
  evals/sample:     9

julia> @benchmark named_tuple_gen($(Ref(obj))[])
BenchmarkTools.Trial: 
  memory estimate:  0 bytes
  allocs estimate:  0
  --------------
  minimum time:     1.541 ns (0.00% GC)
  median time:      1.583 ns (0.00% GC)
  mean time:        1.590 ns (0.00% GC)
  maximum time:     5.791 ns (0.00% GC)
  --------------
  samples:          10000
  evals/sample:     1000

julia> @benchmark FewerBools_as_NT($(Ref(obj))[])
BenchmarkTools.Trial: 
  memory estimate:  0 bytes
  allocs estimate:  0
  --------------
  minimum time:     1.833 ns (0.00% GC)
  median time:      1.917 ns (0.00% GC)
  mean time:        1.948 ns (0.00% GC)
  maximum time:     15.917 ns (0.00% GC)
  --------------
  samples:          10000
  evals/sample:     1000

julia> @code_warntype named_tuple(obj)
Variables
  #self#::Core.Const(named_tuple)
  obj::FewerBools
  #5::var"#5#6"{FewerBools}

Body::NamedTuple
1 ─ %1  = Main.fieldnames($(Expr(:static_parameter, 1)))::Tuple{Vararg{Symbol, N} where N}
β”‚   %2  = Core.tuple(Main.Tuple)::Core.Const((Tuple,))
β”‚   %3  = Main.fieldtypes($(Expr(:static_parameter, 1)))::Tuple
β”‚   %4  = Core._apply_iterate(Base.iterate, Core.apply_type, %2, %3)::Type
β”‚   %5  = Core.apply_type(Main.NamedTuple, %1, %4)::Type{NamedTuple{_A, _B}} where {_A, _B}
β”‚   %6  = Main.:(var"#5#6")::Core.Const(var"#5#6")
β”‚   %7  = Core.typeof(obj)::Core.Const(FewerBools)
β”‚   %8  = Core.apply_type(%6, %7)::Core.Const(var"#5#6"{FewerBools})
β”‚         (#5 = %new(%8, obj))
β”‚   %10 = #5::var"#5#6"{FewerBools}
β”‚   %11 = Main.fieldcount($(Expr(:static_parameter, 1)))::Core.Const(9)
β”‚   %12 = Main.ntuple(%10, %11)::NTuple{9, Bool}
β”‚   %13 = (%5)(%12)::NamedTuple
└──       return %13

julia> @code_warntype named_tuple_gen(obj)
Variables
  #self#::Core.Const(named_tuple_gen)
  obj::FewerBools

Body::NamedTuple{(:x1, :x2, :x3, :x4, :x5, :x6, :x7, :x8, :x9), NTuple{9, Bool}}
1 ─ %1  = Main.getfield(obj, 1)::Bool
β”‚   %2  = Main.getfield(obj, 2)::Bool
β”‚   %3  = Main.getfield(obj, 3)::Bool
β”‚   %4  = Main.getfield(obj, 4)::Bool
β”‚   %5  = Main.getfield(obj, 5)::Bool
β”‚   %6  = Main.getfield(obj, 6)::Bool
β”‚   %7  = Main.getfield(obj, 7)::Bool
β”‚   %8  = Main.getfield(obj, 8)::Bool
β”‚   %9  = Main.getfield(obj, 9)::Bool
β”‚   %10 = Main.tuple(%1, %2, %3, %4, %5, %6, %7, %8, %9)::NTuple{9, Bool}
β”‚   %11 = (NamedTuple{(:x1, :x2, :x3, :x4, :x5, :x6, :x7, :x8, :x9), NTuple{9, Bool}})(%10)::NamedTuple{(:x1, :x2, :x3, :x4, :x5, :x6, :x7, :x8, :x9), NTuple{9, Bool}}
└──       return %11

julia> @code_warntype FewerBools_as_NT(obj)
Variables
  #self#::Core.Const(FewerBools_as_NT)
  obj::FewerBools
  #10::var"#10#11"{FewerBools}

Body::NamedTuple{(:x1, :x2, :x3, :x4, :x5, :x6, :x7, :x8, :x9), NTuple{9, Bool}}
1 ─ %1 = Main.:(var"#10#11")::Core.Const(var"#10#11")
β”‚   %2 = Core.typeof(obj)::Core.Const(FewerBools)
β”‚   %3 = Core.apply_type(%1, %2)::Core.Const(var"#10#11"{FewerBools})
β”‚        (#10 = %new(%3, obj))
β”‚   %5 = #10::var"#10#11"{FewerBools}
β”‚   %6 = Main.fieldcount(Main.FewerBools)::Core.Const(9)
β”‚   %7 = Main.ntuple(%5, %6)::NTuple{9, Bool}
β”‚   %8 = Main.NT(%7)::NamedTuple{(:x1, :x2, :x3, :x4, :x5, :x6, :x7, :x8, :x9), NTuple{9, Bool}}
└──      return %8
2 Likes

I tried my example with 11 fields with fix #29100, make `fieldnames` inferrable by JeffBezanson Β· Pull Request #39832 Β· JuliaLang/julia Β· GitHub; it seems like that PR did successfully make fieldnames infer (as claimed!), but the values still don’t get inferred:

julia> @code_warntype named_tuple(obj)
MethodInstance for named_tuple(::ManyBools)
  from named_tuple(obj::T) where T in Main at REPL[4]:1
Static Parameters
  T = ManyBools
Arguments
  #self#::Core.Const(named_tuple)
  obj::ManyBools
Locals
  #1::var"#1#2"{ManyBools}
Body::NamedTuple{(:x1, :x2, :x3, :x4, :x5, :x6, :x7, :x8, :x9, :x10, :x11), _A} where _A<:Tuple
1 ─ %1  = Main.fieldnames($(Expr(:static_parameter, 1)))::Core.Const((:x1, :x2, :x3, :x4, :x5, :x6, :x7, :x8, :x9, :x10, :x11))
β”‚   %2  = Core.tuple(Main.Tuple)::Core.Const((Tuple,))
β”‚   %3  = Main.fieldtypes($(Expr(:static_parameter, 1)))::Tuple
β”‚   %4  = Core._apply_iterate(Base.iterate, Core.apply_type, %2, %3)::Type
β”‚   %5  = Core.apply_type(Main.NamedTuple, %1, %4)::Type{NamedTuple{(:x1, :x2, :x3, :x4, :x5, :x6, :x7, :x8, :x9, :x10, :x11), _A}} where _A
β”‚   %6  = Main.:(var"#1#2")::Core.Const(var"#1#2")
β”‚   %7  = Core.typeof(obj)::Core.Const(ManyBools)
β”‚   %8  = Core.apply_type(%6, %7)::Core.Const(var"#1#2"{ManyBools})
β”‚         (#1 = %new(%8, obj))
β”‚   %10 = #1::var"#1#2"{ManyBools}
β”‚   %11 = Main.fieldcount($(Expr(:static_parameter, 1)))::Core.Const(11)
β”‚   %12 = Main.ntuple(%10, %11)::Tuple{Vararg{Bool}}
β”‚   %13 = (%5)(%12)::NamedTuple{(:x1, :x2, :x3, :x4, :x5, :x6, :x7, :x8, :x9, :x10, :x11), _A} where _A<:Tuple
└──       return %13

I wonder if Tuple{fieldtypes(T)...} is just the wrong way to write this? Or maybe it’s a remaining inference issue with fieldtypes.

I’m also interested in this, because StructArrays uses something very similar (called staticschema) to turn a type into a named tuple type, eg

ComplexF64 -> NamedTuple{(:re, :im), Tuple{Float64, Float64}}

Have you tried a recursive approach? Maybe something like

julia> tupletype(::Tuple{}) = Tuple{}
tupletype (generic function with 1 method)

julia> function tupletype(t::Tuple)
           f, l = first(t), Base.tail(t)
           return Base.tuple_type_cons(f, tupletype(l))
       end
tupletype (generic function with 2 methods)

and then tupletype(fieldtypes(T))?

On a separate note (this does note fix the StructArrays usecase but may be helpful in your scenario), do you even need the tuple type? Couldn’t you just use

named_tuple(obj::T) where {T} = NamedTuple{fieldnames(T)}(ntuple(i -> getfield(obj, i), fieldcount(T)))

and let it infer the tuple type from the instance?

1 Like

Great ideas!

With this, I get

julia> named_tuple(obj::T) where {T} = NamedTuple{fieldnames(T), tupletype(fieldtypes(T))}(ntuple(i -> getfield(obj, i), fieldcount(T)))
named_tuple (generic function with 1 method)

julia> @code_warntype named_tuple(obj)
MethodInstance for named_tuple(::ManyBools)
  from named_tuple(obj::T) where T in Main at REPL[15]:1
Static Parameters
  T = ManyBools
Arguments
  #self#::Core.Const(named_tuple)
  obj::ManyBools
Locals
  #9::var"#9#10"{ManyBools}
Body::NamedTuple{(:x1, :x2, :x3, :x4, :x5, :x6, :x7, :x8, :x9, :x10, :x11), _A} where _A<:Tuple
1 ─ %1  = Main.fieldnames($(Expr(:static_parameter, 1)))::Core.Const((:x1, :x2, :x3, :x4, :x5, :x6, :x7, :x8, :x9, :x10, :x11))
β”‚   %2  = Main.fieldtypes($(Expr(:static_parameter, 1)))::Tuple
β”‚   %3  = Main.tupletype(%2)::Type
β”‚   %4  = Core.apply_type(Main.NamedTuple, %1, %3)::Type{NamedTuple{(:x1, :x2, :x3, :x4, :x5, :x6, :x7, :x8, :x9, :x10, :x11), _A}} where _A
β”‚   %5  = Main.:(var"#9#10")::Core.Const(var"#9#10")
β”‚   %6  = Core.typeof(obj)::Core.Const(ManyBools)
β”‚   %7  = Core.apply_type(%5, %6)::Core.Const(var"#9#10"{ManyBools})
β”‚         (#9 = %new(%7, obj))
β”‚   %9  = #9::var"#9#10"{ManyBools}
β”‚   %10 = Main.fieldcount($(Expr(:static_parameter, 1)))::Core.Const(11)
β”‚   %11 = Main.ntuple(%9, %10)::Tuple{Vararg{Bool}}
β”‚   %12 = (%4)(%11)::NamedTuple{(:x1, :x2, :x3, :x4, :x5, :x6, :x7, :x8, :x9, :x10, :x11), _A} where _A<:Tuple
└──       return %12

it looks about the same, unfortuantely.

Good point! With that, I get

julia> named_tuple(obj::T) where {T} = NamedTuple{fieldnames(T)}(ntuple(i -> getfield(obj, i), fieldcount(T)))
named_tuple (generic function with 1 method)

julia> @code_warntype named_tuple(obj)
MethodInstance for named_tuple(::ManyBools)
  from named_tuple(obj::T) where T in Main at REPL[11]:1
Static Parameters
  T = ManyBools
Arguments
  #self#::Core.Const(named_tuple)
  obj::ManyBools
Locals
  #7::var"#7#8"{ManyBools}
Body::NamedTuple{(:x1, :x2, :x3, :x4, :x5, :x6, :x7, :x8, :x9, :x10, :x11), _A} where _A<:(Tuple{Vararg{Bool, N}} where N)
1 ─ %1  = Main.fieldnames($(Expr(:static_parameter, 1)))::Core.Const((:x1, :x2, :x3, :x4, :x5, :x6, :x7, :x8, :x9, :x10, :x11))
β”‚   %2  = Core.apply_type(Main.NamedTuple, %1)::Core.Const(NamedTuple{(:x1, :x2, :x3, :x4, :x5, :x6, :x7, :x8, :x9, :x10, :x11), T} where T<:Tuple)
β”‚   %3  = Main.:(var"#7#8")::Core.Const(var"#7#8")
β”‚   %4  = Core.typeof(obj)::Core.Const(ManyBools)
β”‚   %5  = Core.apply_type(%3, %4)::Core.Const(var"#7#8"{ManyBools})
β”‚         (#7 = %new(%5, obj))
β”‚   %7  = #7::var"#7#8"{ManyBools}
β”‚   %8  = Main.fieldcount($(Expr(:static_parameter, 1)))::Core.Const(11)
β”‚   %9  = Main.ntuple(%7, %8)::Tuple{Vararg{Bool}}
β”‚   %10 = (%2)(%9)::NamedTuple{(:x1, :x2, :x3, :x4, :x5, :x6, :x7, :x8, :x9, :x10, :x11), _A} where _A<:(Tuple{Vararg{Bool, N}} where N)
└──       return %10

so now it does infer that the values are Bools, but not how many of them. I think that’s then an ntuple issue, so it seems like fieldtypes inference isn’t an issue here (since we don’t actually need that function, as you point out!).

At some point I wonder if a generated function is actually the right approach, because I suppose that’s the way to ask the compiler to do more calculations than it would otherwise at compile time.

Yes, that’s usually the way to go in situations like this.

1 Like