First of all, I noticed that your CopyTable
has a Vector{Any}
field, which will tend to cause poor performance and additional allocations in general. Replacing it with a Vector{Vars}
does not solve the immediate problem, but it’s still a good idea.
Back to the central issue: We can actually demonstrate it with a much simpler example:
julia> mutable struct Vars
v1::Float64
v2::Float64
v3::Float64
v4::Float64
v5::Float64
end
julia> v = Vars(1, 2, 3, 4, 5);
julia> using BenchmarkTools
julia> @btime setfield!(v, i, x) setup=(i = 1; x = 2.0)
18.975 ns (1 allocation: 16 bytes)
Exactly one allocation per setfield!
call, which is consistent with your results above.
It seems like the compiler just isn’t quite clever enough to optimize this all the way. In general this is a hard problem: given a call like setfield!(::Foo, ::Int, ::Float64)
, the types of the inputs don’t generally provide enough information because the type of the field being set might depend on the value of the field index. It happens that for Vars
that’s not the case because every field is a Float64
, but it’s not entirely surprising that the compiler doesn’t have some special logic to figure that out here.
If your data were truly just a single struct, and you didn’t need it to be mutable, then you could use reinterpret
:
julia> struct ImmutableVars
v1::Float64
v2::Float64
end
julia> reinterpret(ImmutableVars, [1.0, 2.0])
1-element reinterpret(ImmutableVars, ::Array{Float64,1}):
ImmutableVars(1.0, 2.0)
otherwise you might actually want to descend into @generated
function territory. We can generate a function for an input x::T
which generates code that looks like:
setproperty!(x, :field1, values[1])
setproperty!(x, :field2, values[2])
setproperty!(x, :field3, values[3])
by iterating over the field names of our type:
julia> @generated function set_fields(x::T, values::AbstractVector) where {T}
expressions = []
for (i, field) in enumerate(fieldnames(T))
push!(expressions, quote
setproperty!(x, $(QuoteNode(field)), values[$i])
end)
end
return quote
begin
@assert length(values) == $(length(fieldnames(T)))
$(expressions...)
x
end
end
end
set_fields (generic function with 1 method)
julia> v = Vars(1, 2, 3, 4, 5);
julia> set_fields(v, [5, 4, 3, 2, 1])
Vars(5.0, 4.0, 3.0, 2.0, 1.0)
julia> v
Vars(5.0, 4.0, 3.0, 2.0, 1.0)
Since all of the field accesses are now by their literal names, the compiler has no trouble optimizing the resulting expression:
julia> @btime set_fields($v, $([5, 4, 3, 2, 1]))
4.470 ns (0 allocations: 0 bytes)
Vars(5.0, 4.0, 3.0, 2.0, 1.0)
If you want to see more about how the @generated
function works, we can split up the actual @generated
function from the part that builds the expression:
julia> function make_set_fields_expression(T)
expressions = []
for (i, field) in enumerate(fieldnames(T))
push!(expressions, quote
setproperty!(x, $(QuoteNode(field)), values[$i])
end)
end
return quote
begin
@assert length(values) == $(length(fieldnames(T)))
$(expressions...)
x
end
end
end
make_set_fields_expression (generic function with 1 method)
julia> @generated function set_fields(x::T, values::AbstractVector) where {T}
make_set_fields_expression(T)
end
set_fields (generic function with 1 method)
Here’s the expression that is built. Note how each field in Vars
has been stuck literally into the resulting expression:
julia> make_set_fields_expression(Vars)
quote
#= REPL[42]:9 =#
begin
#= REPL[42]:10 =#
#= REPL[42]:10 =# @assert length(values) == 5
#= REPL[42]:11 =#
begin
#= REPL[42]:5 =#
setproperty!(x, :v1, values[1])
end
begin
#= REPL[42]:5 =#
setproperty!(x, :v2, values[2])
end
begin
#= REPL[42]:5 =#
setproperty!(x, :v3, values[3])
end
begin
#= REPL[42]:5 =#
setproperty!(x, :v4, values[4])
end
begin
#= REPL[42]:5 =#
setproperty!(x, :v5, values[5])
end
#= REPL[42]:12 =#
x
end
end