NamedTuple type is unstable, or my usage is wrong?

Hello everyone
help me see a question, I want to define a NamedTuple type that can accept multiple parameters, but I can’t do it well.
pm::NamedTuple((:b, ),Tuple(Int64)) #This definition is fast, but it can only accept one parameter
pm::NamedTuple{names,T} where {names, T <: Tuple{Vararg{Int64}}}#This can accept multiple parameters, but the speed is very slow
pm::NamedTuple(names,T) where (names, T <: Tuple))#This can accept multiple parameters, but the speed is very slow, and there will be a lot of extra memory allocation 5.762435 seconds (100.00 M allocations: 1.490 GiB, 0.83% gc time)

g.pm=(b=3,a=1,c=2) Here is the case of multiple parameters

I don’t know what is wrong, is NamedTuple defined in the wrong type, or is it used in the wrong way? Or is there any other class that can replace NamedTuple?

Any help will be very appreciated!

Below is the code

mutable struct TGlblvar
  #pm::NamedTuple{(:b, ),Tuple{Int64}} #This definition is fast, but it can only accept one parameter
  #pm::NamedTuple{names,T} where {names, T <: Tuple{Vararg{Int64}}}#speed slow
  pm::NamedTuple{names,T} where {names, T <: Tuple} #)#This can accept multiple parameters, but the speed is very slow, and there will be a lot of extra memory allocation

  #pm::NamedTuple{names} where names 
  #pm::NamedTuple{(:b, )}
  #pm::NamedTuple{(:b, ),Tuple{Any}} 
  #pm::NamedTuple{names,Tuple{Vararg{Int64}}} where names
  #pm::NamedTuple{names,Tuple{Int64,Int64}} where names
  #pm::NamedTuple{names,T} where {names, T <: Tuple{Int64,Int64}}
  #pm::NamedTuple{names,T} where {names<:Vararg{Symbol}, T <: Tuple{Vararg{Int64}}}
  #pm::NamedTuple{(:b, ),T} where {T <: Tuple{Int64}}
  #pm::NamedTuple{<:Any, <:Tuple{Vararg{Int64}}}
  function TGlblvar()
    return new()
  end
end

function test()
  g=TGlblvar()
  g.pm=(b=3,)
  cc = 0
  nt=g.pm
  for i = 1:10^8 
    cc = cc + nt.b
  end
  println(g.pm, cc)
end

function ttt()
  @time test()
  #@code_warntype  test()
  #@code_typed test()
end

ttt()

Hi @Zq_F! There is a solution to this. First off, there’s nothing inherently wrong with the definition

mutable struct TG1b1var
    pm::NamedTuple{names,T}
end

since (as you noticed) you can store any NamedTuple you want into the field pm. Sometimes, this might be exactly what you want to do.

From a performance stand point, though, it’s not optimal because the compiler does not have enough information to know the exact type of the field pm when it’s accessed since that information is not propagated into TG1b1var.

The solution to make it fast is to add type parameters to the definition of TG1b1var like this:

mutable struct TG1b1var{names,T}
    pm::NamedTuple{names,T}
end

function test()
    # Since we added type information, we now have to create `g` 
    # directly from the NamedTuple instead of creating it ahead of time.
    g = TG1b1var((b = 3,))
    cc = 0
    nt = g.pm

    for i in 1:10^8 
        cc = cc + nt.b
    end
    println(g.pm, cc)
end

using BenchmarkTools
@btime test()
# Final time
# 13.610 μs (30 allocations: 800 bytes)

Now, one limitation here is that you can’t change the type of the field pm anymore. For example,

julia> x = TG1b1var((a = 3,))
TG1b1var{(:a,),Tuple{Int64}}((a = 3,))

# Assigning same type of NamedTuple
julia> x.pm = (a = 4,)
(a = 4,)

# Different type of NamedTuple
julia> x.pm = (a = "hello world!",)
ERROR: MethodError: Cannot `convert` an object of type String to an object of type Int64
...

# But, we can construct a new parameterized instance of TG1b1var
julia> y = TG1b1var((a = "hello world!",))
TG1b1var{(:a,),Tuple{String}}((a = "hello world!",))

# But note that now `x` and `y` have different types
julia> typeof(x)
TG1b1var{(:a,),Tuple{Int64}}

julia> typeof(y)
TG1b1var{(:a,),Tuple{String}}

I hope this helps! I’m pretty new to posting here, so if I didn’t explain things clearly enough, let me know and I can, you know, try to do better! :smiley:

5 Likes

@hildebrandmw Thanks for answering.
Sorry, maybe I didn’t describe it clearly.

One is that this TGlblvar variable has many fields, not just pm.

The second is that pm will not be created together when creating Tglblvar, and the type of pm is not known at this time. The type of pm and several parameters are determined later according to the situation

Okay, a few things here:
Your test with a literal 3 in it is broken, since the compiler just constant-propagates it away if possible, better make the number you assign random or an argument. Secondly, there’s a thing called function barrier. In short, you want to separate the parts of a function in which the types are known/inferrable and don’t change from the rest. See the shielded_test function below. Ideally, you want to go one step further and extract the parameters you need from the struct once and feed them to an inner function, which will then be faster, because it doesn’t need to extract fields from a tuple in each iteration. See proper_test in the code below. If you do that cleverly, storing mixed types there is totally fine if you help the compiler figure out what’s going on.

using BenchmarkTools
mutable struct T1
  pm::NamedTuple{(:b, ),Tuple{Int64}} #This definition is fast, but it can only accept one parameter
  function T1()
    return new()
  end
end
mutable struct T2
  pm::NamedTuple{names,T} where {names, T <: Tuple{Vararg{Int64}}}
  function T2()
    return new()
  end
end
mutable struct T3
  pm::NamedTuple{names,T} where {names, T <: Tuple} #)#This can accept multiple parameters, but the speed is very slow, and there will be a lot of extra memory allocation
  function T3()
    return new()
  end
end

function test(g)
  g.pm=(b=3,)
  cc = 0
  nt=g.pm
  for i = 1:10^8
    cc = cc + nt.b
  end
  return(g.pm, cc)
end

function shielded_test(g)
  g.pm = (b=rand(Int),)
  cc = 0
  nt = g.pm
  return (g.pm, _inner_shielded_test(nt,cc))
end
function _inner_shielded_test(nt,cc)
  for i = 1:10^8
    cc = cc + nt.b
  end
  return cc
end

function proper_test(g)
  g.pm = (b=rand(Int),)
  cc = 0
  nt = g.pm
  return (g.pm, _inner_proper_test(nt.b,cc))
end
function _inner_proper_test(b,cc)
  for i = 1:10^8
    cc = cc + b
  end
  return cc
end

println("Previous test:")
b1 = @benchmark test(g) setup = g = T1()
b2 = @benchmark test(g) setup = g = T2()
b3 = @benchmark test(g) setup = g = T3()
println("""
T1: Time: $(string(b1)), Allocs: $(b1.allocs)
T2: Time: $(string(b2)), Allocs: $(b2.allocs)
T3: Time: $(string(b3)), Allocs: $(b3.allocs)
""")

println("Shielded test:")
b1 = @benchmark shielded_test(g) setup = g = T1()
b2 = @benchmark shielded_test(g) setup = g = T2()
b3 = @benchmark shielded_test(g) setup = g = T3()
println("""
T1: Time: $(string(b1)), Allocs: $(b1.allocs)
T2: Time: $(string(b2)), Allocs: $(b2.allocs)
T3: Time: $(string(b3)), Allocs: $(b3.allocs)
""")
println("Proper test:")
b1 = @benchmark proper_test(g) setup = g = T1()
b2 = @benchmark proper_test(g) setup = g = T2()
b3 = @benchmark proper_test(g) setup = g = T3()
println("""
T1: Time: $(string(b1)), Allocs: $(b1.allocs)
T2: Time: $(string(b2)), Allocs: $(b2.allocs)
T3: Time: $(string(b3)), Allocs: $(b3.allocs)
""")

which gives:

Previous test:
T1: Time: Trial(1.299 ns), Allocs: 0
T2: Time: Trial(1.246 s), Allocs: 2
T3: Time: Trial(3.790 s), Allocs: 99999831

Shielded test:
T1: Time: Trial(9.300 ns), Allocs: 0
T2: Time: Trial(1.563 s), Allocs: 100000003
T3: Time: Trial(97.048 ns), Allocs: 3

Proper test:
T1: Time: Trial(9.201 ns), Allocs: 0
T2: Time: Trial(89.958 ns), Allocs: 4
T3: Time: Trial(114.332 ns), Allocs: 4

Just for reference: T3() is then able to store further fields of arbitrary types without hurting performance:

# pass second argument to the tuple as a field "c"
julia> @benchmark shielded_test(g, "hi there") setup = g = T3()
BenchmarkTools.Trial: 
  memory estimate:  80 bytes
  allocs estimate:  3
  --------------
  minimum time:     95.679 ns (0.00% GC)
  median time:      101.474 ns (0.00% GC)
  mean time:        109.132 ns (3.64% GC)
  maximum time:     2.803 μs (94.33% GC)
  --------------
  samples:          10000
  evals/sample:     949

julia> @benchmark proper_test(g, Complex(1,2)) setup = g = T3()
BenchmarkTools.Trial: 
  memory estimate:  112 bytes
  allocs estimate:  4
  --------------
  minimum time:     111.435 ns (0.00% GC)
  median time:      120.279 ns (0.00% GC)
  mean time:        132.702 ns (6.05% GC)
  maximum time:     4.053 μs (96.10% GC)
  --------------
  samples:          10000
  evals/sample:     927
2 Likes

Ahh - I think I understand now. I agree completely with @FPGro, you’re looking to use the function barrier pattern here. I.E., the pm field can be left untyped (or just typed as something like a NamedTuple). Wrap any performance critical code in a function, and before calling that function, unpack the needed fields into something concretely typed, like another NamedTuple.

struct TG1b1var
    pm::NamedTuple
    a
    b
    ...
end

x = TG1b1var()
x.pm = (a = 3,)
...
# unpack needed fields into a NamedTuple.
# The compiler won't know ahead of time the type of `nt`,
# But the object `nt` will have a concrete type.
nt = (pm = x.pm, a = x.a, b = x.b)

# Passing `nt` to a performance critical function will dispatch to
# the right function and specialize that function for the runtime type 
# of `nt`.
my_perforance_critical_function(nt)
1 Like

Thanks for your reply
I found a pkg, that I feel should solve my problem perfectly. Put it up first, and try it performance tomorrow